Stream: project-ffi-unwind

Topic: cost of supporting longjmp without annotations


view this post on Zulip BatmanAoD (Kyle Strand) (Feb 11 2021 at 20:41):

RalfJ said:

I guess there is a discussion to be had if this is an optimization that we want. I am really proud that Stacked Borrows can provide it, but there's clearly a trade-off. OTOH, this is an optimization that can benefit all code vs a rather niche feature. Still, worth a discussion it seems.

I believe we're ready to have that discussion now. We published a blog post outlining how the annotation-based support for longjmp would work, but did not receive any responses to it over the course of two weeks. So, in the weekly meeting topic, we're wondering whether this optimization is actually worth adding the extra complexity of an annotation.

@bjorn3 may also want to contribute to the discussion.

view this post on Zulip oliver (Feb 11 2021 at 21:50):

Everything I know about longjmp I learned in this blog: https://developer.r-project.org/Blog/public/2019/03/28/use-of-c-in-packages/index.html

view this post on Zulip nikomatsakis (Feb 24 2021 at 11:05):

So @Amanieu wrote that the "only cost" with longjmp is not being able to move things to after calls -- that seems correct to me, except that I think this is kind of a big deal. We've been working hard to enable those sort of transformations. Now, you could argue that this is unnecessary, especially given that panic! is something we have to contend with!

view this post on Zulip nikomatsakis (Feb 24 2021 at 11:05):

How can we make this discussion more evidence-based and less assertion-based? :)

view this post on Zulip nikomatsakis (Feb 24 2021 at 11:05):

(cc @RalfJ)

view this post on Zulip nagisa (Feb 24 2021 at 13:33):

With my "works around backend" hat on, I would quite hate having to keep possibility of longjmp in my head rather than expressed explicitly in code.

view this post on Zulip Connor Horman (Feb 24 2021 at 15:37):

I'm not sure that's quite true, unless you consider observable behaviour (IE. volatile access, I/O).
The C standard specifies that after a longjmp, any non-volatile object (might also be constrained to automatic storage duration) modified since the corresponding setjmp has an indeterminate value, which translates to uninit in rust. So if you have

let mut x = 5;
if unsafe{setjmp(buf)} ==0{
    x = 6;
    unsafe{longjmp(buf,1))}
}else{...}

then in the else branch, x is uninit, so the assignment can be moved, or even removed.

Of course, this presents a slightly different problem (in that x is now uninit, which is currently UB). Though if indeterminate is taken here as unspecified, it would be fine (but llvm may or may not be ok with that interpretation).

view this post on Zulip nikomatsakis (Feb 24 2021 at 16:24):

nagisa said:

With my "works around backend" hat on, I would quite hate having to keep possibility of longjmp in my head rather than expressed explicitly in code.

yes

view this post on Zulip nikomatsakis (Feb 24 2021 at 16:25):

@Connor Horman that's interesting; there are I suppose other potential ways to go about specifying that we could do code motion

view this post on Zulip nikomatsakis (Feb 24 2021 at 16:25):

it would require, I think, sacrificing some of the "ease of use" that stacked borrows is shooting for

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 24 2021 at 16:31):

nagisa said:

With my "works around backend" hat on, I would quite hate having to keep possibility of longjmp in my head rather than expressed explicitly in code.

I agree with this (which is why I called the C status quo of "longjmp can happen anywhere, any time" a "wart"), but my concern is that it will be one more pain point in the "playing nice with existing systems software" domain.

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 24 2021 at 16:36):

(To be clear: this is a fear, not an appraisal. I agree with @nikomatsakis that we need to find a way to make this discussion more evidence-based, and I would love to see evidence that my fear is misplaced, but I don't yet see a path toward gaining that confidence.)

view this post on Zulip Josh Triplett (Feb 24 2021 at 17:24):

I suspect that actual usage of longjmp will be sufficiently rare that it seems perfectly reasonable to require either annotation or a compiler flag.

view this post on Zulip Josh Triplett (Feb 24 2021 at 17:24):

The programs that need it really need it.

view this post on Zulip Josh Triplett (Feb 24 2021 at 17:25):

But at the same time, the fraction of programs, even systems programs, using it is quite small.

view this post on Zulip Amanieu (Feb 24 2021 at 19:48):

An alternative definition is that the contents of a &mut are undefined after a longjmp: after all the &mut promised exclusive access for the duration of the function but the longjmp violated that expectation by returning control early.

view this post on Zulip nikomatsakis (Feb 24 2021 at 20:32):

I feel like I don't want the contents to be undefined

view this post on Zulip nikomatsakis (Feb 24 2021 at 20:32):

people will wind up relying on them

view this post on Zulip nikomatsakis (Feb 24 2021 at 20:32):

I want them to be defined to be what they should be

view this post on Zulip nikomatsakis (Feb 24 2021 at 20:32):

and I just want the compiler to know when a longjmp happens

view this post on Zulip nikomatsakis (Feb 24 2021 at 20:32):

since, as you said, it's unusual

view this post on Zulip nikomatsakis (Feb 24 2021 at 20:32):

maybe the question: what is the case against an annotation?

view this post on Zulip nikomatsakis (Feb 24 2021 at 20:33):

we should probably make a little doc to collect these things

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 24 2021 at 21:34):

The case against, I think, is that there is absolutely nothing the compiler can do to detect when longjmp actually happens, without like... reading C code, or guessing based on function signatures. So if an annotation is required for correctness, then there is ample opportunity for UB with un-annotated functions.

view this post on Zulip Amanieu (Feb 25 2021 at 00:18):

Keep in mind that pthread_exit on non-glibc will directly exit the thread, which is equivalent to longjmp. So we would need this annotation for every FFI call which can potentially cause a thread exit. This includes all libc functions which are cancellable.

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 00:30):

I think that's okay, as long as libc functions are typically called via the libc crate and that crate updates to use the annotations.

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 00:33):

I also tend to think that it may be worthwhile to make any optimizations that would invalidate un-annotated code using longjmp opt-in, at least up until an edition boundary.

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 00:34):

I.e. I think we would ideally support the annotation itself in all editions, but 2015 and 2018 would have a guarantee that longjmp across POFs wouldn't be UB. (Or maaaaaaybe there could be a compiler flag for it, though that's probably more trouble than it's worth.)

view this post on Zulip Josh Triplett (Feb 25 2021 at 16:37):

That sounds reasonable.

view this post on Zulip RalfJ (Feb 25 2021 at 18:56):

BatmanAoD (Kyle Strand) said:

I also tend to think that it may be worthwhile to make any optimizations that would invalidate un-annotated code using longjmp opt-in, at least up until an edition boundary.

does LLVM perform any such optimizations? If yes, it might be had to make this opt-in.

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 18:58):

I can't imagine it would, since in C++ it's always legal to longjmp over POFs.

view this post on Zulip RalfJ (Feb 25 2021 at 18:58):

and C++ doesn't have C-style rules like "all these values become indeterminate"? Because if it does, then LLVM would well be in its right to "invalidate" such code, at least in the sense that the values it works on become all messed up. I'd be rather surprised if LLVM doesn't do such optimizations.

view this post on Zulip RalfJ (Feb 25 2021 at 18:59):

I agree with @nikomatsakis that rules like "all values X become indeterminate/uninit/... on longjmp" are massive footguns. It is really hard to program correctly against those rules, and really easy to miss some variable and accidentally rely on its value. I could imagine implementing something like this in Miri (so making this rule operational is not the main issue), but still it'd be hard to make sure one gets this right (such low-level code often cannot run in Miri since it interacts with things Miri cannot emulate).

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 19:00):

To be honest I don't quite understand the C rule, but I would expect that C++ has at least as much UB as C concerning longjmp.

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 19:00):

So let me backtrack on that "can't image" phrasing a bit :laughing:

view this post on Zulip RalfJ (Feb 25 2021 at 19:01):

also FWIW, edition-specific optimization flags are... tricky, since optimizations work on MIR/LLVM IR where code from many editions can be mixed (e.g. after inlining)

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 19:05):

I was imagining that prohibiting such optimizations would be equivalent to marking all functions with the "cancelable" annotation. I.e., prior to the 2021 edition, the "cancelable" annotation would only be useful for diagnostics and wouldn't impact codegen.

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 19:14):

ISO C11:

except that the values ofobjects of automatic storage duration that are local to the function containing theinvocation of the correspondingsetjmpmacro that do not have volatile-qualified typeand have been changed between thesetjmpinvocation andlongjmpcall areindeterminate.

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 19:14):

(7.13.2.3)

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 19:15):

oh jeeze, sorry about the formatting there

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 19:22):

Since setjmp can't be used via something like return setjmp(env), a Rust invocation of setjmp would, I think, need to pass a callback in order to invoke further Rust code prior to longjmp. So I think the only place where variables are left in an indeterminate state is in the C code itself...?

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 19:23):

Which would of course be a problem for Rust if the C code passes those variables to the Rust callback, so that should probably be illegal

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 19:23):

But I think any variables defined by Rust would not be invalidated by the C runtime behavior. At least, I don't see any reason in the C standard for that to happen.

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 19:25):

BatmanAoD (Kyle Strand) said:

Since setjmp can't be used via something like return setjmp(env), a Rust invocation of setjmp would, I think, need to pass a callback in order to invoke further Rust code prior to longjmp. So I think the only place where variables are left in an indeterminate state is in the C code itself...?

...or the C itself could invoke Rust. That case also seems relatively tame, though.

view this post on Zulip Connor Horman (Feb 25 2021 at 21:35):

and C++ doesn't have C-style rules like "all these values become indeterminate"? Because if it does, then LLVM would well be in its right to "invalidate" such code, at least in the sense that the values it works on become all messed up. I'd be rather surprised if LLVM doesn't do such optimizations.

According to https://eel.is/c++draft/csetjmp.syn, it's just C's setjmp.h, but it further restricts longjmp to those that cross "pof"s.

Since setjmp can't be used via something like return setjmp(env), a Rust invocation of setjmp would, I think, need to pass a callback in order to invoke further Rust code prior to longjmp. So I think the only place where variables are left in an indeterminate state is in the C code itself...?

Ah yeah, that's true (unless you grab a compiler builtin version from somewhere, and call it).

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 22:06):

In C99 and onward, C has essentially the same limitation, because apparently variable-length arrays are heap-allocated and therefore freed by something akin to a destructor. (TIL; I thought C somehow cleverly supported variable-size stack elements.)

view this post on Zulip Connor Horman (Feb 25 2021 at 22:23):

Actually, that's not quite true.
Jumping into the scope of a VLA after leaving it is UB, but crossing the scopes of other VLAs is not (it's just unspecified whether those get deallocated when you do so). (Also, VLAs can be stack allocated, and they can be heap allocated)

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 25 2021 at 22:33):

Ah. Okay, interesting.

view this post on Zulip nagisa (Feb 26 2021 at 00:04):

You can also register cleanups in GCC with __cleanup__ which probably doesn't interact at all well with setjmp/longjmp

view this post on Zulip Amanieu (Feb 26 2021 at 16:56):

__cleanup__ is basically a C++ destructor. It is ignored by longjmp.

view this post on Zulip Amanieu (Feb 26 2021 at 16:58):

The setjmp API is a horribly hack that only exists because C doesn't have closures. A safe Rust API for it would be fn setjmp(f: impl FnOnce(JmpBuf<'_, E>) -> T) -> Result<T, E>.

view this post on Zulip Amanieu (Feb 26 2021 at 17:00):

However this is not the issue here (we're never going to support the setjmp hackery in Rust anyways). The issue is essentially: longjmp skips destructors, so it is equivalent to an unwind if there are no destructors. Is the Rust compiler allowed to insert destructors out of thin air (e.g. by sinking code past a function call into both the unwind and normal paths).

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 26 2021 at 17:43):

Amanieu said:

However this is not the issue here (we're never going to support the setjmp hackery in Rust anyways). The issue is essentially: longjmp skips destructors, so it is equivalent to an unwind if there are no destructors. Is the Rust compiler allowed to insert destructors out of thin air (e.g. by sinking code past a function call into both the unwind and normal paths).

This is equivalent to "can the compiler turn POFs into non-POFs", right? Or am I misunderstanding?

view this post on Zulip Amanieu (Feb 26 2021 at 18:23):

Yes, essentially

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 26 2021 at 18:27):

So, I think the answer I lean toward is: in 2015 and 2018, no. In 2021 and onward, yes, unless a function is annotated "cancelable".

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 26 2021 at 18:29):

(and in 2015 and 2018, if the compiler can prove that the function can't be present in a stack where arbitrary C code might be invoked before the function returns, it can go ahead and insert such destructors anyway)

view this post on Zulip nikomatsakis (Feb 27 2021 at 09:21):

Amanieu said:

However this is not the issue here (we're never going to support the setjmp hackery in Rust anyways). The issue is essentially: longjmp skips destructors, so it is equivalent to an unwind if there are no destructors. Is the Rust compiler allowed to insert destructors out of thin air (e.g. by sinking code past a function call into both the unwind and normal paths).

it's not just this

view this post on Zulip nikomatsakis (Feb 27 2021 at 09:22):

because if you have -Cpanic=abort, then it's not about inserting destructors

view this post on Zulip nikomatsakis (Feb 27 2021 at 09:22):

RalfJ said:

also FWIW, edition-specific optimization flags are... tricky, since optimizations work on MIR/LLVM IR where code from many editions can be mixed (e.g. after inlining)

This doesn't seem right, because of what @BatmanAoD (Kyle Strand) said -- in the MIR, functions require an annotation, but the compiler inserts it on your behalf during MIR lowering.

view this post on Zulip nikomatsakis (Feb 27 2021 at 09:24):

Amanieu said:

Keep in mind that pthread_exit on non-glibc will directly exit the thread, which is equivalent to longjmp. So we would need this annotation for every FFI call which can potentially cause a thread exit. This includes all libc functions which are cancellable.

It feels to me like putting burdens on folks who use pthread_exit is ok. You already have to be very careful on how you use this. I guess, like @BatmanAoD (Kyle Strand) said, that this can be handled by a central abstraction as well.

view this post on Zulip nikomatsakis (Feb 27 2021 at 09:24):

I'm feeling like this discussion has gone long enough that it'd be useful to have a write-up.

view this post on Zulip RalfJ (Feb 27 2021 at 12:15):

BatmanAoD (Kyle Strand) said:

Since setjmp can't be used via something like return setjmp(env), a Rust invocation of setjmp would, I think, need to pass a callback in order to invoke further Rust code prior to longjmp. So I think the only place where variables are left in an indeterminate state is in the C code itself...?

So setjmp is only "toxic" to local variables in the function that invokes it, but not in other functions? that makes no sense at all to me, why would outlining make any difference here?

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 27 2021 at 14:09):

Hm, maybe because they may or may not be captured and restored restored by the jumpbuf? I don't know. I agree it doesn't seem to make much sense.

view this post on Zulip BatmanAoD (Kyle Strand) (Feb 27 2021 at 14:12):

That's all that the standard calls out in that section, at least.


Last updated: Jan 26 2022 at 07:20 UTC