[Pre-RFC] Panic checker


#1

Summary

Allow functions and closures to be classified based on whether they may or may not panic. Add an unsafe attribute that allows the compiler to assume that a particular section of code will never panic.

Motivation

  • Some functions should not panic. Examples include: drop (possibly bad idea?), closure argument to take_mut, Rust callbacks called from C (FFI). If they panic, the program would abort, or in the case of FFI callbacks undefined behavior would result.
  • Removing panics may slightly improve performance.

Detailed design

Overall goals

  • Maintain as much backward compatibility as possible
  • Enable a reasonable amount of expressiveness without compromising type inference
  • Minimize the amount of magic built into the type system (or try to … at least)

Panic parameter

Functions are classified into two categories:

  • Functions known to panic
  • Functions known to not panic

The categorization is done by adding a new special parameter to the Fn traits called 'panic:

pub trait Fn<'panic, Args>: FnMut<'panic='panic, Args> {
    extern "rust-call" fn call(&self, args: Args)<'panic> -> Self::Output;
}

pub trait FnMut<'panic, Args>: FnOnce<'panic='panic, Args> {
    extern "rust-call" fn call_mut(&mut self, args: Args)<'panic> -> Self::Output;
}

pub trait FnOnce<'panic: *Panic, Args> {
    type Output;
    extern "rust-call" fn call_once(self, args: Args)<'panic> -> Self::Output;
}

The 'panic parameter is special: it is neither a lifetime parameter nor a type parameter. The kind of the 'panic parameter is *Panic. The ordinary fn(…) -> … and Fn(…) -> … syntax for types would be extended to:

fn::<'panic=…>(…) -> …
Fn::<'panic=…>(…) -> …

For definitions of functions and closures:

fn …(…)<'panic=…> -> … { … }
|…|<'panic=…> -> … { … }

Functions that never panic have 'panic=false. Functions that do panic have 'panic=true.

For backward compatibility reasons:

  • The parameter is optional in both Fn(…) -> … and fn(…) -> …. If omitted, it is inferred (see “Panic inference” below), similar to lifetimes. Therefore, the 'panic parameter must be specified using the keyword syntax (the 'panic= part is mandatory).
  • If the 'panic parameter is omitted from impl<'panic> Fn<'panic, …>, then it is presumed true.
  • If the 'panic parameter is omitted from the method of any trait, then it is presumed true. The Drop::drop method could be modified to 'panic=false. This is not a breaking change (see “Panic unification” below).

Panic inference

The inference of the 'panic parameter is based on the contents of the function:

  • If a function f calls any other function with 'panic=true, then the function f has 'panic=true as well.
  • Otherwise, if the function f calls various generic functions with 'panic=p1, 'panic=p2, …, 'panic=pN, then the function f has 'panic=p1 || p2 || … || pN where || is Boolean OR.
  • Otherwise, the function f has 'panic=false.

Functions imported from C are always 'panic=false.

Panic unification

If the panic checker expects 'panic=false for some function/closure argument, but the function argument is actually 'panic=true, then:

  • The frontend will issue a warning, with a note telling the user that they can silence the warning by overriding the inferred 'panic on the function being passed as an argument.
  • Any panics within the function will be translated into termination.

The above can also happen if the panic checker finds a function exported to C that is inferred to be 'panic=true.

It is permissible for the panic checker to expect 'panic=true for some function/closure argument when the function argument is actually 'panic=false. No warnings will be issued.

If a function is declared as 'panic=false, but the function is inferred to be 'panic=true, then any panics within the function will be translated into termination. No warnings will be issued, as the user is presumed to know what they are doing by overriding panic inference.

If a function is declared as 'panic=true, but the function is inferred to be 'panic=false, then there is no effect.

Optimizations

If a region of code is inferred to be 'panic=false, the compiler is permitted to use this assumption for optimizations.

Unsafe no-panic assumption

A new block attribute is added: #[unsafe_assume(no_panic)]. This can only be attached to an unsafe block:

#[unsafe_assume(no_panic)]
unsafe {
    …
}

It is undefined behavior if any of the enclosed code panics. In some sense, #[unsafe_assume(no_panic)] is like declaring 'panic=false, but there are no guard rails to protect the program from undefined behavior if it does happen.

Potential use: if an area of code uses indexing and/or arithmetic heavily, changing panics into UB allows the compiler to optimize away checks if the code gets inlined. (Obviously this is extremely dangerous and its use should be strongly discouraged!)

How We Teach This

Because it does not require changes to existing code, beginners probably won’t stumble into it until they start using Drop (?), take_mut, or FFI callbacks.

The panic parameter should remain mostly invisible in the code, but in the documentation it would be a good idea to show it explicitly.

#[unsafe_assume(no_panic) should be banished to the Rustonomicon. It’s a really dangerous tool and should not be used lightly.

Drawbacks

  • The panic tracking system seems awfully complicated for a relatively minor benefit.
  • Unsafe assumptions about whether the code panics can be very dangerous since a lot of the seemingly harmless code like x + y can panic!

Alternatives

  • Convert panic conflicts into errors instead of warnings.
  • Implement only #[unsafe_assume(no_panic)] to allow performance optimizations, but disregard the parameter-based panic-tracking system because the latter adds a lot of complexity.
  • Not bother tracking panics at all and just rely on documented invariants. ¯\_(ツ)_/¯

Unresolved questions

Can panics be tracked without introducing so much magic into the type system?

Could the technique be generalized to track other useful invariants and/or function attributes (e.g. purity)?


#2

A general effect system would be nice to have. It could be used to track and/or prohibit panics, IO, global access, heap allocations, etc in a transitive fashion.


#3

Rust actually had an effect system in the pre-1.0 era.


#4

I don’t think drop is a good inclusion here. If an impl of Drop has to do cleanup that is able to fail, the only reasonable thing to do in the case of failure is panic (probably with an if !thread::panicking() conditional).

It’s unclear to me what the actual goal of this RFC would be. Is the benefit the ability to specify that you only take a closure/fn which does not panic in certain scenarios? The vague “Removing panics may slightly improve performance” seems unlikely to pan out in the presence of inlining/monomorphisation


#5

Good point. I was under the impression that panicking in drop was illegal, but guess not.

That’s the main reason. It also gives a way to signal intent for folks who want to absolutely avoid panics in e.g. embedded code.

I’m confused here. I’d assume inlining and monomorphization both improve the performance of #[unsafe_assume(no_panic)] code. Say I have a piece of code like this:

fn get_thing(&self) -> Something {
    #[unsafe_assume(no_panic)]
    unsafe { self.vec[0] }
}

Normally, the codegen can’t assume that the vector is nonempty and therefore needs to generate code to check its length as well as to panic. However, #[unsafe_assume(no_panic)] declares that panic is undefined behavior within the scope. Therefore, the optimizer is free to strip out the check entirely.