UB does not time travel - #2320
Conversation
|
What is the advantage of providing this guarantee? Since everyone (as far as I'm aware) aspires to produce code which is entirely UB-free, I can't imagine someone wanting to rely on this. Plus, as you say, it constrains us to specific versions of LLVM (and presumably to LLVM, period – what about Cranelift or other future backends?). Maybe we could weaken this to say that we don't currently have time-traveling UB when compiling with the standard LLVM backend, but that this isn't a stability promise, and that it may not apply to other backends? |
|
The advantage is that if you do dbg!(...);
some_function_that_maybe_has_ub();then you will reliably see that debug output before the UB. It is quite frustrating when you can't debug your null ptr deref because the eprintln!("going to load from {ptr:p}");
let _val = ptr.read();and then seeing a crash without the print. Now you may think that the crash occurs from some other operation before the print. But actually the Of course you don't rely on this for an actually deployed program. But you are not unlikely to rely on this while debugging a program and figuring out what the heck it is doing and where it is going wrong. |
|
That's a good point, but presumably it'd still be useful to those users for us to document this without documenting it as a stable guarantee? I'd need to think more to convince myself that this is actually true, but I think that this guarantee would make it harder for Aeneas and Anneal to model UB (AeneasVerif/aeneas#1225). Currently the proposal is to model UB as a kind of "absorbing state" in which, once you reach UB, that's all that Aeneas says about your execution. If pre-UB effects are observable, then we'd need to expose those effects in addition. I suppose could just say "Aeneas's model is strictly weaker than – but not inconsistent with – what Rust itself guarantees", but I'd like to avoid that, at least for Anneal, if possible. It makes it harder to keep straight the correspondence between Aeneas/Anneal and upstream Rust, makes it harder for us to explain to users, etc. |
I feel quite strongly that time-traveling UB is something we don't want to do. Time-traveling UB defies people's intuition and is often used as an example for "look at this silly thing the compiler did" (and I can't even really argue that people are wrong when saying that). Even C finally got rid of time-traveling UB by accepting N3128, albeit as a recommendation rather than a normative requirement (IIUC). Time-traveling UB is a disservice to our users. Therefore, we shouldn't do it, and we should promise not to do it.
Note that this model is already wrong. Consider a program like this: fn main() {
let mut buffer = String::new();
let _ignore = io::stdin().read_line(&mut buffer);
unsafe { std::hint::unreachable_unchecked() };
}If I run this program and then hit Ctrl-C when it waits for input, that's an entirely well-defined execution. The compiler must create code that handles that execution correctly. It seems you are saying Aeneas would model this execution as equivalent to a program that always has UB; that is an incorrect model. The only operations where UB as a sort of "absorbing" state is a correct model are operations that are guaranteed to always return. Many I/O operations are already allowed to never return and models have to deal with that. |
|
@rustbot label +I-lang-nominated |
I think it would be a sad outcome if trying to support more formal reasoning tools would lead to Rust making fewer useful (and formally meaningful) promises to its users. It is true that this can complicate modeling Rust programs with observable behavior. But I think that complication is well-invested effort to make Rust behavior better aligned with people's intuitions and with what we actually want the compiler to do. If Aeneas/Anneal anyway proves that a program cannot reach UB on any path then I don't think it should cause significant complications. Complications mostly arise if you want to define the semantics of programs that sometimes do and sometimes do not have UB, and what it means to correctly compile such a program. |
|
Interesting. For my part, I see why this makes sense. Probably the most surprising consequence, that I see, is that it prevents hoisting loop-invariant code such as this: /// Sample a device register, scaling each sample by a
/// configurable value read through `cfg`.
pub unsafe fn sample(reg: *const u64, cfg: *const u64, out: &mut Vec<u64>) {
for slot in out.iter_mut() {
let v = unsafe { reg.read_volatile() }; // Observable.
*slot = v.wrapping_mul(unsafe { *cfg }); // `*cfg` is loop-invariant.
}
}But there are other ways this could be optimized. |
|
|
||
| r[undefined.behavior] | ||
| When a Rust program encounters undefined behavior, the program may perform arbitrary operations, including but not limited to jumping to arbitrary other code (even dead code) elsewhere in the program, performing arbitrary syscalls, or jumping into memory that does not hold valid machine code. | ||
| However, undefined behavior does not "time travel": if an observable operation (I/O or a volatile accesses) occurs before the point in the source code where undefined behavior was triggered, that observable operation is guaranteed to be executed before the program encounters undefined behavior. |
There was a problem hiding this comment.
Presumably we mean to define this in terms of execution order (as the C folks did) rather than source code order. E.g.:
for i in 0..10 {
unsafe { maybe_ub(i) }; // UB only when `i == 5`.
println!("{i}"); // After the UB point in the source code.
}There was a problem hiding this comment.
Yes that is what I meant. I concur that "source code order" is a bad term, but "execution order" begs the question -- which execution? The one in the Abstract Machine or the one on the Concrete Machine? Not all things happen in the same order in both.
In the memory model this is often called "program order", not sure if that is more clear. C++ calls it "sequenced-before".
There was a problem hiding this comment.
I wonder whether something like "source execution order" would make this clearer. I assume that part of the thrust of this proposal is that optimizations that that execute println!("5") before maybe_ub(5) are permitted only when maybe_ub(5) is guaranteed to have well-defined behaviour (which, in this case, is false). OTOH, I assume that the rust compiler and LLVM do perform some optimizations that can cause the machine to execute instructions "out-of-order with respect to source execution order", no?
There was a problem hiding this comment.
Yeah, something like that.
I assume that the rust compiler and LLVM do perform some optimizations that can cause the machine to execute instructions "out-of-order with respect to source execution order", no?
This is mostly an ill-posed question. You'd have to first define what you mean by this, and the only "anchor" you have to relate the two executions is observable behavior (I/O and volatile). In between, the compiled program can to basically anything it wants in any order, as long as the observable behavior is the same (or more precisely: a refinement of) the observable behavior of the source program.
|
That example could be hoisted by unrolling the first iteration. Also note that LLVM 23 already stopped doing that optimization as it considers volatile reads to maybe-trap, so we'd have to put in some effort to get it back if we truly caree about it.
|
That sounds more like a Quality of Life issue than something that needs to be a stable language guarantee to me. Or something that could be controlled by some compiler flag, like digama0 proposed. Also, regarding IO, this seems more of a library guarantee than a language guarantee. Or would you say that a rust crate which exposes a safe function which writes to a file would be unsound if it were implemented with an Footnotes
|
|
Whether UB is properly ordered wrt observable behavior is a core property of the very notion of execution of the AM. It is not a "quality of life" issue. It will affect what definitions one has to put into Rocq/Lean to model what a correct compilation of Rust even is.
Yes. Libraries don't get to break basic language properties such as how observable behavior and UB interact. The only such option we have currently are various forms of things called "pure", and obviously it's UB to do I/O in anything called "pure". |
|
After many years of people making fun for how silly C is to have time-traveling UB (me included), I am honestly quite shocked that anyone would argue in favor of such an extreme interpretation of UB. I have not the faintest idea why that is. In an alternative universe where UB has always interacted with observable events in a proper way I cannot imagine a proposal to make UB "swallow" previous I/O would have even the slightest chance of acceptance. The only reason Rust ever had time-traveling UB is because we were forced into it by LLVM. In other words, I expected this to be a slam dunk with people celebrating the great news that we got rid of this wart in the language. Oh well, looks like I'll have to actually argue for this. And argue I shall :) |
|
Where should that argument happen? A Reference PR doesn't seem like the right place. |
|
Probably on the UCG issue and/or the UCG thread for this. |
Why not? It's where I planned to propose FCP.
Note that @digama0 proposed this in a time when it seemed like we'd have to actually change what the compiler does not get no-timetravel. That's not the case any more. The latest rustc nightlies (since the LLVM 23 update) do not have time-traveling UB. The compiler already respects the stricter semantics. I don't think a flag makes sense here. If anyone can ever make a convincing case in favor of time-traveling UB (which I haven't seen yet, even the much more sane |
Because it mixes up deciding what the model is and deciding how to describe the model in the Reference. |
This comment was marked as resolved.
This comment was marked as resolved.
|
Speaking as a Reference maintainer, I think it's OK to debate the desired guarantees here, just as we'd debate desired semantics on a But I don't expect this to be controversial. |
|
Let's propose to do this (modulo wording tweaks to clarify this is about the execution order). @rfcbot fcp merge lang,opsem |
|
@traviscross has proposed to merge this. The next step is review by the rest of the tagged team members:
No concerns currently listed. Once a majority of reviewers approve (and at most 2 approvals are outstanding), this will enter its final comment period. If you spot a major issue that hasn't been raised at any point in this process, please speak up! cc @rust-lang/lang-advisors: FCP proposed for lang, please feel free to register concerns. |
|
@rfcbot reviewed |
So we are making a stable guarantee that we will never add a "willreturn" option for |
|
I think we are making a stable guarantee that nothing that exists in the language today behaves like that. This does not preclude adding a new form "observable event that is not sequenced wrt UB" in the future. |
|
@traviscross wrote:
I think this hoist is wrong even if you allow time-travelling UB. A volatile read is allowed to have side effects (this is pretty much the entire point of volatile), and a You could make the hoist legal by making |
Not quite. A volatile access must not have side-effects on AM-visible state, it can only have effects on state "outside" the AM. That's already documented. That is the main difference between a volatile access and an inline asm block: the volatile access basically has extremely precise clobbers that say "does not read or write any memory (that the compiler may care about) except for the location explicitly read/written here". So the loop hoist is indeed an optimization that we could have if we had time-traveling UB. |
558dd36 to
717f57f
Compare
|
I just remembered that I may have written code already which depends on this, or at least on a closely-related guarantee (cc rust-lang/unsafe-code-guidelines#565): /// Stores `t` in stack memory and provides a `&'static mut T` to a function
/// which will never return.
///
/// Since `F: FnOnce(...) -> !`, the stack memory holding `t` will never be
/// reclaimed, and so it can live forever (ie, be referenced using `&'static mut
/// T`).
///
/// If `f` unwinds, `with_static` will `loop {}` forever to prevent `t` from
/// being reclaimed.
pub fn with_static<T: 'static, F: FnOnce(&'static mut T)>(t: T, f: F) -> ! {
struct LoopOnDrop<T>(T);
impl<T> Drop for LoopOnDrop<T> {
fn drop(&mut self) {
#[allow(clippy::empty_loop)]
loop {}
}
}
let mut t = LoopOnDrop(t);
let tp: *mut T = &mut t.0;
// SAFETY: Since we `drop(t)` after the `loop {}`, `t` will only go out of
// scope if `f` unwinds. If this happens, `LoopOnDrop::drop` will `loop {}`
// forever before its inner `T` is destructed. Thus, `t.0` will never be
// destructed, and so it is sound to synthesize a `'static` reference to
// `t`.
//
// See for more analysis: https://github.com/rust-lang/unsafe-code-guidelines/issues/565
f(unsafe { &mut *tp });
#[allow(clippy::empty_loop)]
loop {}
#[allow(unreachable_code)]
drop(t);
}Since This isn't exactly equivalent to this PR's guarantee, since the |
@RalfJung Do you believe that the guarantee added by this PR is required in order to argue that this example program is UB-free (specifically in the execution in which the user hits Ctrl-C before providing input)? If so, then I think it may be unavoidable that we need to add this guarantee, as I agree that such an execution should be UB-free. |
|
That isn't made sound by time traveling UB. Even with time traveling UB, |
tl;dr it's complicated, but probably not. If we allow UB to time travel, then surely we can only allow it to time-travel across I/O operations (or syscalls / observable events in general) that are guaranteed to return. For instance So your question boils down to "is I don't know how C compilers decide which I/O operations / syscalls are guaranteed to return (and hence permit time traveling) and which are not. I would prefer if in Rust we just avoided having to even have such discussions by saying that we don't have time-traveling UB, which is basically equivalent to saying that all observable events may fail to return back to the program. |
|
This reaches my point on the UCG thread about an OS shipping metadata about its system calls that (among other things), can indicate whether or not a given syscall is allowed to diverge (synchronously), and a compiler being allowed to infer things like llvm |
Maybe we could say "UB does not time travel over observable events, except for observable events which are explicitly marked. We do not currently have any such marked observable events, but we reserve the right to add them in the future. All observable events which are not marked are guaranteed to never be marked in the future." That would allow most of the reasoning we want today without allowing some crazy "an observable event happens here, but I don't know which one, but that's fine and I can still assume no time-traveling UB". As long as you know which event happens, you're allowed to assume no time-traveling UB. |
|
Given that we always can add new language operations with new semantics and new kinds of UB, I don't think we should privilege "syscalls that allow time-traveling UB" by explicitly mentioning them as a future possibility. |
|
My concern is that, if we say "no observable events permit time-traveling UB", then a user could reason that that's true of any event whatsoever, including in a context where they write code which is generic over the specific event. That code would become unsound if we ever add an event that does permit time-traveling UB. |
Well, there is no (whole) program which is sound without time-traveling UB but not with it, so I wouldn't be too concerned. |
|
I am having trouble imagining what kind of "context generic over the specific event" you are thinking of. Typically we don't have code that is generic over "give me some observable event", it just says "give me some arbitrary callback and I will call it". Arbitrary callbacks can already be |
One example would be a framework for memory-mapped I/O. Perhaps the user supplies the address of a known memory-mapped I/O register, and the framework performs volatile reads/writes for them. Declaring such a register would of course be You could similarly imagine frameworks for other kinds of events (maybe user-supplied snippets of inline assembly, maybe user-supplied syscall numbers, etc) which would make similar assumptions. But more to the point, I prefer to err on the side of not predicting what future uses will exist, especially when the cost of guarding against those future uses small (in this case, an extra sentence or two in the Reference). In my experience, predicting that a future use won't exist is often wrong, and the downside of slightly more verbose wording is much less of a big deal than the downside of ecosystem UB. |
|
For volatile reads/writes, we decided in rust-lang/rust#160564 that they can trap, which is already a hard guarantee that UB cannot time-travel across them.
I'd like to see at least one example of the kind of program that would actually be written differently if we added something about having different kinds of observable events in the future. My thesis is that there is no such program. |
|
Like, even without involving the possibility of different kinds of observable events, I can't think of a library that would rely on "UB does not time travel". You keep saying a library "might internally reason that UB cannot time travel"; I cannot think of a case where that reasoning would come up or be helpful. The library must never have UB anyway, so whether or not it can time-travel isn't relevant. I don't think the guarantee I am proposing here is useful for libraries. It is primarily useful for programmers that write |
|
|
||
| r[undefined.behavior] | ||
| When a Rust program encounters undefined behavior, the program may perform arbitrary operations, including but not limited to jumping to arbitrary other code (even dead code) elsewhere in the program, performing arbitrary syscalls, or jumping into memory that does not hold valid machine code. | ||
| However, undefined behavior does not "time travel": if an observable operation (I/O or a volatile accesses) occurs before the point in the source execution order where undefined behavior was triggered, that observable operation is guaranteed to be executed before the program encounters undefined behavior. |
There was a problem hiding this comment.
How about a simpler, smaller carve-out?
| However, undefined behavior does not "time travel": if an observable operation (I/O or a volatile accesses) occurs before the point in the source execution order where undefined behavior was triggered, that observable operation is guaranteed to be executed before the program encounters undefined behavior. | |
| However, undefined behavior does not "time travel": if an observable operation (I/O or a volatile accesses) occurs before the point in the source execution order where undefined behavior was triggered, that observable operation is guaranteed to be executed before the program encounters undefined behavior. If an observable operation which permits time travel is added in the future, it will be marked as such. |
I don't feel super strongly about this – not enough to block FCP. IMO it'd be nice to at least nod at this so that careful authors understand the distinction. That said, I wasn't able to come up with a clear example of relying on "this generic observable operation prevents UB time travel".
There was a problem hiding this comment.
What does "marked" mean?
The way I think this would go, if we ever wanted to support such operations, would be by adding a willreturn flag on asm that's similar to pure but allows I/O as long as the asm block can guarantee that that I/O will always return. A similar #[ffi_willreturn] attribute could be added for extern blocks. The documentation for those flags/attributes would then say "this can cause UB after the operation to time-travel to before the operation, causing whoever has to debug this code a loss of most of their hair".
Are you saying that we should promise that if we ever add such an attribute, that we will document it thoroughly to mention this caveat? That seems like an odd thing to say, I think the expectation is that we document every attribute we add. :)
There was a problem hiding this comment.
| However, undefined behavior does not "time travel": if an observable operation (I/O or a volatile accesses) occurs before the point in the source execution order where undefined behavior was triggered, that observable operation is guaranteed to be executed before the program encounters undefined behavior. | |
| However, undefined behavior does not "time travel": by default, if an observable operation (I/O or a volatile accesses) occurs before the point in the source execution order where undefined behavior was triggered, that observable operation is guaranteed to be executed before the program encounters undefined behavior. | |
| > [!NOTE] | |
| > There is currently no way to opt-out of that default. Such a mechanism may be added in the future, but that will not break any code not using that mechanism. |
This is my attempt to steelman your proposal. I still would prefer not to add such a note, but this is the version I dislike the least. ;) I'd like to hear from the rest of the involved teams regarding whether we should add this or not.
There was a problem hiding this comment.
@RalfJung The only case I can think of where we might want this in the future would be much narrower than that. Essentially, if we add some specific operation which is allowed to time-travel, then UB interacting with that operation could time-travel to the extent that operation can time-travel.
There was a problem hiding this comment.
I don't know what you mean by "time-traveling operation". UB isn't an operation, so what we call "time-traveling UB" doesn't mean an operation is doing time-traveling. "no time-traveling UB" really just means "observable behavior is an optimization barrier for potentially-UB operations", or colloquially "if you print something before doing UB then you'll see the print".
We obviously allow e.g. two adjacent calls to read() to be reordered. That's not time-traveling, that's just a standard semantics-preserving transformation. And we don't allow observable behavior to be reordered; if you print first A then B you'll always see them in that order. There's not really any degrees of freedom here I can think of.
There was a problem hiding this comment.
Sure – it makes it so that the default (ie, if an operation provides no documentation one way or another) is that UB can't time-travel over an operation, but saying "unless it documents otherwise" reserves the possibility that some operation might document it, which prevents the kind of generic reasoning I'm worried about.
There was a problem hiding this comment.
But it would be wrong for an operation to document this. Docs can't change the opsem. We'd have to first add a new language feature that makes such operations exist. That's what I expressed in my variant of this.
There was a problem hiding this comment.
And surely we will never have an operation that is allowed to have observable behavior before its invocation.
I can think of any number of such operations I'd like to see us have that would be perfectly reasonable. (You can have things observable before their apparent invocation, through compiler or processor reordering.) Let's not go on this tangent right now, it's not a blocker for the current proposal.
There was a problem hiding this comment.
I have written papers about operations that have this flavor so this is a topic I thought about quite a bit. I think this would break the ability for people to reason about what their program does; I don't think any sensible language can have operations like that. You get into causal loops and internal inconsistency basically immediately. Compilers and processors already do all sorts of reordering, none of them have this effect.
But anyway -- what do you propose for this PR then?
There was a problem hiding this comment.
@RalfJung For this PR I would propose that we not add any extra language disclaiming future things we might do, and close this subthread.
|
@rfcbot reviewed |
1 similar comment
|
@rfcbot reviewed |
|
@rfcbot reviewed I'm happy we can make this guarantee. The fact that references are dereferenceable and immutable still gives us plenty of flexibility in how we implement optimizations for real-world Rust code. |
|
🔔 This is now entering its final comment period, as per the review above. 🔔 |
Fixes rust-lang/unsafe-code-guidelines#407 by saying that our UB does not have time travel semantics (in relation to observable behavior such as I/O and volatile accesses).
This does not require any compiler changes. The compiler already does not do time-traveling UB, we just need to change the docs to turn this into a promise for our users.
Note that this relies on LLVM 23. With LLVM 22, UB can time travel across volatile reads. We use LLVM 23 but still allow compiling with LLVM 22, though we also document that
I don't know to what extent we consider inofficial builds of Rust (with different LLVM versions) as being governed by the Reference.
Cc @rust-lang/opsem @rust-lang/lang