-
Notifications
You must be signed in to change notification settings - Fork 59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Is longjmp
through rust code UB
#404
Comments
I think it should be possible for at least some trivial versions of setjmp/longjmp to be sound. For example if both setjmp and longjmp calls are in C and they don't jump over any rust stack frames, then it should be possible to argue that this is just FFI having some lang-specific behavior which isn't relevant, as long as it does not explicitly manifest as "impossible behavior" from the rust code's perspective. The next level of difficulty is jumping from C to C over a Rust stack frame which has no destructors, and here I think it's still fine, since this is as-if you just unwind those frames. (We may want to make this UB if there is a "nounwind" annotation of some kind. Possibly this is already true of all C calls, in which case this and everything after is UB at the unwind.) If you jump from C to C over a Rust stack frame which has destructors, I still think it is fine as regards immediate UB, but this is unsound behavior and may trigger UB in subsequent rust code since a destructor was skipped. For C to Rust or Rust to C, the question does not arise because (for a variety of reasons) the libc crate does not support setjmp or longjmp, last I checked. So you would have to go design that interface first. |
So, at work we call setjmp (sigsetjmp, really), and then immediately call into C, which may longjmp over Rust stack frames (ones which have no destructors) back to our setjmp. At the moment, it's almost certainly a form of UB (one which works in practice very reliably at the moment), since we don't use the (still-unstable) "ffi_returns_twice" attribute1 I would strongly prefer this not be immediately UB though, since this kind of thing would be desirable to support and isn't that uncommon -- any Rust library that wants to call a C library that uses sjlj-based error-handling will want to do something like this. Footnotes
|
We don't support Calling unsafe fn setjmp<F: FnOnce() -> c_int>(env: *mut jmp_buf, f: F) -> c_int {
let mut f = core::mem::MaybeUninit::new(f);
extern "C" fn call_f<F: FnOnce() -> c_int>(f: *mut F) -> c_int {
(unsafe { f.read() })()
}
let ret: c_int;
std::arch::asm!(
"call setjmp",
"test eax, eax",
"jne 1f",
"mov rdi, r12",
"call {}",
"1:",
sym call_f::<F>,
in("rdi") env,
in("r12") f.as_mut_ptr(),
lateout("rax") ret,
clobber_abi("C"),
);
ret
} |
Note that any library that has that would have to be careful arround |
Things that definitely make
Things that may make
|
By the way, I don't think that soundness of longjmp is our (t-opsem's) concern. We are only concerned about what causes immediate UB, and soundness is a derived property of libraries that don't cause UB. So |
Well we do care about defining the semantics in a way that Pin is sound. ;) However, setjmp/longjmp cannot be UB in the sense of "the AM stops with an error when you do this", since the AM doesn't even have such an operation. So this is really a subquestion of the general question of what FFI/asm can do to the Rust AM implementation state, which seems to live at the intersection of t-opsem and t-compiler. |
We define frame deallocation of non-POFs to be undefined. |
If frames to be deallocated consist of only POFs, then from Rust AM's perspective that's no different from just unwinding, given that no code is to be executed anyway? |
The question basically boils down to: is the compiler allowed to add unwind landing pads when the original code doesn't have any? If so then it is UB to skip these with longjmp, even if the original program has no droppable types in a frame. |
It could, though. It would be something like an unwind from a function with a flag in the exception state to skip all destructors (call it "forced unwind"). This would then otherwise act just like an unwind, except that destructors would not be run on the way out. If you hit a nounwind function, you get UB. (There might also be functions which are no-normal-unwind which are okay with an unwind that skips destructors, in which case only "normal" unwinding is UB, not forced unwind. This is I think the common case for function calls that lack a cleanup block continuation.) Obviously, forced unwind is unsafe since it breaks safety invariants, but it's not undefinable in the AM, and having a sane definition of what it does on the AM will avoid some of the weirder states this operation would otherwise end up in. Of course adding an operation to the AM means that it is now a thing you have to worry about happening from any opaque or external function, but you can control the fallout from that by liberally sprinkling "no-forced-unwind" on such functions until you can be sure they can be supported. |
On Wed, May 24, 2023 at 16:57 Mario Carneiro ***@***.***> wrote:
However, setjmp/longjmp cannot be UB in the sense of "the AM stops with an
error when you do this", since the AM doesn't even have such an operation.
It could, though. It would be something like an unwind from a function
with a flag in the exception state to skip all destructors (call it "forced
unwind"). This would then otherwise act just like an unwind, except that
destructors would not be run on the way out. If you hit a nounwind
function, you get UB. (There might also be functions which are
no-normal-unwind which are okay with an unwind that skips destructors, in
which case only "normal" unwinding is UB, not forced unwind. This is I
think the common case for function calls that lack a cleanup block
continuation.)
Note that skipping destructors is not even guaranteed - on SEH destructors
are run and catches will "catch" a longjmp. I do not know enough about how
panics are implemented on SEH to say whether this can even be fiesibly
disabled.
… Obviously, forced unwind is unsafe since it breaks safety invariants, but
it's not *undefinable* in the AM, and having a sane definition of what it
does on the AM will avoid some of the weirder states this operation would
otherwise end up in. Of course adding an operation to the AM means that it
is now a thing you have to worry about happening from any opaque or
external function, but you can control the fallout from that by liberally
sprinkling "no-forced-unwind" on such functions until you can be sure they
can be supported.
—
Reply to this email directly, view it on GitHub
<#404 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ABGLD25XNOYXHNN7C6RJRE3XHZY3NANCNFSM6AAAAAAYMO67D4>
.
You are receiving this because you authored the thread.Message ID:
***@***.***>
|
I think that would be handled by having |
LLVM will optimize out destructors only reachable when unwinding from calls to nounwind functions, yet it allows to longjmp out of a nounwind function just fine. This means that even on windows is undefined if destructors run or not and which ones do run. |
That sounds pretty much like undefined behavior. I can't possibly see how LLVM could justify such a thing otherwise. It is quite reasonable to assert that |
See rust-lang/rust#88243 (comment) and the comment after that. |
Ugh, this is awkward. I don't think we want to expose the LLVM behavior of evaluating a random subset of the destructors as that is impossible to code for, and for the most part this is already LLVM UB:
But the last sentence here is a cop-out which exposes this subsetting behavior and classifies it as "implementation defined". I'm not sure how a longjmp can be classified as a "trap or asynchronous exception" though, since it is clearly synchronous and not a trap AFAIK. Apparently this tortured reading is used to justify using nounwind in MSVC even when longjmp is used. I would prefer we expose two reliable behaviors: either all of the destructors are evaluated, or none of them are. If we can't ensure either of those two behaviors, it should just be declared as UB. However, I don't think we need that UB fallback, as long as rustc stops using Alternatively, we could just go the route of C++ and declare it UB to jump over any destructors. That amounts to eliminating the second "no destructors are evaluated" unwind mode from the AM, but it would make longjmp and pthread_kill immediate UB in the majority of cases, which seems worse to me since I doubt that will stop people from doing it anyway. |
We definitely can't guarantee all destructors to be evaluated. For example, in glibc start_routine -> Rust Frame 1 -> C Frame 2 -> Rust Frame 3 -> pthread_exit then it will unwind through frame 3 and skip over frame 1 & 2. Needless to say that For Windows, we currently run all destructors for
I don't get the reasoning. How do we reason about code that relies on destructors on stack being run without declaring skipping destructors on stack UB? The POF requirement for ensuring longjmp/pthread_kill to be non-UB sounds very reasonable to me. |
My concern is that Rust makes use of destructors for a lot of things, and there are a lot of destructors which are probably fine to skip (either no-ops or memory leaks) and making it UB to have any destructors IMO doesn't respect an operational nature for UB. Ideally there shouldn't be any difference between a destructor that does nothing and no destructor (especially since rustc has a lot of implementation detail smarts in the drop checker which may make it difficult as a user to determine whether in fact there is a destructor in the frame). Taking drop flags into account, you really can't even know statically whether a destructor will run, so detecting no-op destructors would require adding a magic extra op into the opsem to signal that a destructor is running, even if no drop code is present.
This might actually be okay, since it can be modeled as a normal unwind through frame 3, followed by a catch and rethrow in the C code to no-drop unwind through 1 & 2. |
Deallocating frames is a very niche use-case in any language, and must always be treated with caution by users regardless of what we do to make it safer. It fundamentally cannot interact well with the concept of RAII, whether the language with RAII patterns is Rust, C++, or anything else. The POF restriction is not just a reasonable restriction, but is the minimal restriction we can make; if we ever do make certain use-cases of I'm not really sure what you mean by "an operational nature for UB." But I do not see any reasonable possibility of defining any kind of "well-defined" drop/destructor behavior in the presence of |
To be clear, I think that the POF restriction is reasonable for the safety condition (among probably quite a few other conditions). I just don't think it's appropriate for the opsem itself. |
Why not? There's a middle ground between "well-defined" and "undefined" - something like "the implementation nondeterministically runs or skips the destructor". This would imply that the compiler could not perform any optimizations that assume that destructors always run (are there any?), but libraries still could do so (including the standard library). |
Yes, we could do that. I really hope we don't, but we could. Nondeterminism here would yield exponentially many possibilities, which is practically impossible for users to handle correctly and would also make Miri sad. Having a deterministic but platform specific behavior seems better to me. |
In my view, the Rust op.sep simply has no way to longjmp over a stack frame. So the question of an operational spec does not even come up. Instead, "what happens on longjmp" is similar to questions like "what happens when I change the stack pointer": this is about the (compiler-controlled) invariant that relates the real machine state with the AM state, and what we say users are required to do when modifying the real machine state in a way that it not possible with AM operations. |
It makes sense to me to define it as not literally "calling FFI A calls into Rust code which calls back into FFI B, but then execution never returns from FFI B to the Rust code's ABI-designated return address, and instead, any or all of the following happen:
In other words, FFI B But we still have to define what happens to the Abstract Machine in that case, unless we want to leave |
Again, this is just like messing with the stack pointer: it involves changing Rust-controlled state from outside Rust. We can of course state that certain ways of doing that are okay. But what we are looking at here is below the level of abstraction of the Abstract Machine. We don't "have to" define anything, but in this case it sounds like we want to say that if all the frames removed from the stack are POF then this is fine. |
In the case of POF, the implementation can likely decide that this is allowable completely independent from the AM op.sem, as the implementation can "merely" implement the AM semantics such that manipulating the stack pointer in a controlled fashion behaves as-if/identically-to an unwind with the same control flow w.r.t. observable effects. Thus, "can you do this" feels like a rustc/implementation question rather than an AM/opsem question. However, it can become an opsem question if we want to guarantee that doing so is allowed no matter what target is implementing Rust, by partially specifying how the abstract semantics are lowered to target operations (here, likely by defining the concept of next operation in a controllable manner). Given Rust defines some things roughly in terms of C (e.g. core::ffi, extern/repr "C"), we do have a soft expectation that the Rust AM is lowered to a target capable of hosting the C AM, so it is arguably relevant to at least mention exotic capabilities of the C AM (e.g. longjmp) in the opsem, even if just as a note that the Rust AM does not provide a direct equivalent and any use that impacts Rust frames is at best nonportable. TL;DR: this is a question of the AM to target lowering, not of the AM opsem. It's debatably in the broad domain of UCG but not T-opsem. |
If we allow longjmp, then we can't create landing pads out from nowhere. i.e.: fn foo(x: &mut u32) {
*x += 1;
bar();
} cannot be transformed to fn foo(x: &mut u32) {
defer! { *x += 1; }
bar();
} |
Isn't this already true? My read of https://github.com/rust-lang/rfcs/blob/master/text/2945-c-unwind-abi.md#plain-old-frames implies that we already have a notion of a plain-old-frame as not having destructors, and thus such a transformation already being forbidden. I could be mistaken though. |
The whole point of defining POF in the RFC is to make longjmp over code with destructors undefined. So my argument is that this is indeed an AM/opsem question. (unless I misunderstand the scope of opsem team) |
Do note that the RFC as accepted says that POF is necessary but not sufficient for forced unwinds to be allowed, and that there is no sufficient condition as of yet. The only thing set by that RFC is that a forced overwind over a non-POF is definitely UB. (That actually answers one of @chorman0773's questions; per that RFC, The opsem just defines what happens, not how it's done. This is admittedly a weird edge case, since generally the Rust AM can be thought of as capable of directly hosting the C AM (i.e. by direct translation of semantics, not by interpreter or porting), but |
I want to reiterate that I see no reason whatsoever for longjmp to be something "outside the AM". It's literally just an unwind that skips destructors. There could be a flag for this and everything. There are some platform-specific details to work out, especially on non-POF frames, but assuming we ignore that situation / declare it UB there is not much else necessary to do to make this a legal citizen. Of course, if you longjmp anywhere other than "up the stack" then I think the comments about this being similar to directly setting the instruction pointer apply, and that should just be immediate UB. But the normal use of this in C code is not that problematic. ( |
Sure we could add longjmp to the Rust AM but IMO we should only do that if Rust actually provides setjmp/longjmp as an operation itself. I don't see why we would carry operations in the AM that we aren't even using as part of the language. |
I think those are somewhat independent questions, since exposing it to rust also means designing an API for it and that can also take some time. Having it in the AM means users are allowed to implement longjmp wrappers themselves, which is generally good enough for all the users that currently want this, but sure we can try to make a version that is exposed from the standard library. It would still be unsafe though, of course. |
Considering this to be an FFI interaction is also good enough for current users I think.
|
I'm not sure what you mean by that. If this is not an AM operation, then it is not allowed for FFI to do it either, unless the effect is completely unobservable from the rust side. That means no jumping over rust frames, POF or otherwise. I generally view the set of operations available to the AM as the mechanism we use to make precise "FFI can do anything rust code could do", where "what rust code could do" is interpreted as "some legal sequence of AM operations". Adding an operation to the AM without adding rust surface syntax for it is thus saying that this is an operation that FFI and inline assembly is allowed to do. |
My thinking was that for POF, the effect is unobservable from the Rust side, since it's just like a regular unwind. That's why POF are okay to just jump over. But indeed this doesn't force the compiler to preserve the property of being a POF, so I guess we do need more than that... hm. |
On the "Can a POF become a non-POF" question, inlining is also fun - can a non-POF function be inlined into a POF (thus implicating longjmp). I thought of this while asking a tangentially-related question on the project-ffi-unwind zulip stream. |
I don't think inlining should pose any special issues, since the "frame" isn't just the entire stack frame but rather the set of live droppable locals at the point of a call to a nested function, and so even within the same function you could have multiple nested calls in which from the point of one call this call frame is a POF and from another it's not. From that perspective inlining can only introduce droppable locals which are not live at the point of existing nested calls, unless you move Perhaps it could be allowed to extend a |
@digama0 Sorry for the late response. I think you're exactly correct that inlining isn't a special case for POFs, and I also think it's safe to say that the compiler should always be free to delete empty
The definition of POF is:
The second sentence is intended to be a pure extrapolation from the first; the "cannot cause any observable effects" is the only "normative" part of the definition, so to speak. So if calling ...that said, what we ultimately want is for users to be able to guarantee that frames are POFs (so that they can |
A subset of this question is whether it's permitted to Purely from an opsem perspective, it can be argued that this subset of the question is actually a T-libs question, since it falls out of the language definition of when forced unwinding is permitted and how std defines its The default Early termination of a thread is unquestionably This would prohibit the compiler from moving observable behavior into unwind handlers, but tbf it seems relatively unlikely to benefit from doing so in practice. The most reasonable thing to move is de/alloc, and that's already considered not observable behavior. Footnotes
|
@CAD97 Agreed. The introduction of the "POF" terminology in RFC 2945 is intended to make it possible to formalize this. |
On the other hand, I think it is entirely reasonable to require that exiting a thread with |
That is fair, and I only really mentioned specific functions as an example of the discussed functionality. Back on the "permit it" hand, though, Rust does document that spawned threads correspond to real OS-level threads, including exposing the
Just to be clear, while it's easy to prove a forced unwind over POF sound (as no unwind handlers exist to skip), I expect we'll likely want to permit forced unwinds over non-POF, with the presented motivating example being a thread exit doing a forced unwind across a |
If we do so, we'd have to say it's unspecified whether they run destructors, because we can't actually promise they aren't or are. |
I don't think we should make |
I would say that if it runs destructors, it's a cooperative unwind, not a forced unwind. Although I do suppose usual usage of the term is more about how/whether the unwind is stopped rather than destructors. If a "portable" unwind source runs destructors on some targets, then it's a cooperative unwind on those targets, and a forced unwind on the targets where it skips destructors. And to note, at least on Windows (a notable example where
The additional language complexity is relatively small; it's just to define what it means to forced unwind over non-POF. The soundness concern is a library concern; obviously it's unsound to do a forced unwind over stack pinned values or soundness-relevant unwind cleanup. The gain is being able to do so, and the main motivating example being able to early-exit a thread. An actual use case of which being exiting the process main thread without terminating the process. Requiring POF and considering a forced unwind over any nontrivial unwind handlers to be UB is still a viable choice, and one mirroring the effective status of C++, but is not without its limitations. And for full transparency: I'm not certain that the more permissive option is actually desirable. It's moreso that I think it's potentially desirable and worth considering on merit. |
And what's a usecase for that? Right now my thinking is that I don't see sufficient motivation to allow |
What is the result of using
setjmp
in C, entering rust code, then calling longjmp, either from Rust, or after first returning intoC
. Is this considered undefined behaviour or defined behaviour.Because of
Pin
(and likely others, things likereplace_with
), this cannot be sound in general, but there is a question of whether it is considered immediately undefined behaviour or whether it is defined in Rust.This intersects with @rust-lang/wg-ffi-unwind. From https://rust-lang.zulipchat.com/#narrow/stream/210922-project-ffi-unwind/topic/cost.20of.20supporting.20longjmp.20without.20annotations and https://rust-lang.zulipchat.com/#narrow/stream/210922-project-ffi-unwind/topic/longjmp.20rules the likely answer is "Undefined Behaviour" even through frames without destructors/catch_unwind.
In addition to determining the desired behaviour, we should consider what guarantees we've made that would make
longjmp
unsound (beyondPin
).(Cc prior broader discussion: #211)
The text was updated successfully, but these errors were encountered: