Panic bounds in the type system level for guaranteeing panic-free code

Why

In today's Rust, the concept of panic, a critical runtime error, exists outside of the type system. While this allows us to code without thinking much about it, people who code in embedded, bare-metal, or do FFI, really do have to. There have been multiple efforts to tackle this outside of the language, like extra tooling or linker scripts, but these are workarounds and cumbersome at best.

How

We currently have different traits for functions: Fn, FnOnce, and FnMut. On closures, these are implemented based on what the closure does with its captured variables. My idea is to have another Fn trait for those that do not panic, e.g. FnNoPanic, which is implemented by all functions that may never (safely) trigger stack unwinding. Then, we would be able to do let x: impl FnNoPanic = || foo(); in order to ensure panic safety. This way, if foo() called code that, in turn, could panic, it would result in a compilation error.

Attribute

In order to have these checks performed at the function declaration boundary, the most straightforward way would be an attribute. We could have a #[no_panic] attribute that ensures that the function implements the FnNoPanic trait. With this header, the function will not compile if it may panic.

Semver

However, this is a HUGE semver hazard. Changing the internal code of a function (not the interface) would have an effect on the callers! For this reason, I propose this to not be part of semver unless the attribute is used. I fear this is not enough and would create lots of footguns, so a reasonable solution would be to have the FnNoPanic trait be only implemented if the attribute is present or all called functions have this attribute; and then have a way to unsafely implement it if a function from another crate doesn't implement it, but should.

Function pointers

Functions pointers do and will not make any guarantees about panic safety, so any function pointer that can't be tracked while type-checking (function pointers as function arguments, for example), are assumed not to implement FnNoPanic.

External calls

Any external function (rust ABI or not) must implement the FnNoPanic trait unsafely. This is because the compiler is unable to check them directly.

Unsafe implementation of the attribute

Unsafe attributes are a feature that has been discussed for quite long, and we could use them to implement this unsafely. Bikeshed: #[unsafe(no_panic)].

8 Likes

Related: the c_unwind feature is the opposite feature for extern ABIs: this sounds like an extern "rust-no-unwind" in those terms?

Not sure if Rust could do variance across ABI types, but that would make the Fn trait variant a bit easier, though I think you would want variants of all three.

There might be way to make the Fn ABI an associated type?

Probably the best way to handle this is via effects.

1 Like

Wouldn't we need FnMutNoPanic and FnOnceNoPanic too then, in addition to FnNoPanic?

1 Like

Without commenting on the rest of the post, I think that you'd need 3 new traits, one analog for each of the current traits. Or, alternatively, a marker trait that can be + NoPanic slapped on each of the current Fn traits, which would likely require additional work eg in allowing impl Trait in let bindings and type fields.

1 Like

Dereference a pointer inserts an (aborting) panics on dereferencing unaligned pointers when using debug assertions. Would this also be forbidden for FnNoPanic? In the future we will have likely have even more cases of UB for which we check. Having those extra checks be a breaking change would be bad.

4 Likes

Is Iterator::map NoPanic? I've posted to the "effects system" threads before that my concerns are around the implications for higher-order functions and communicating the traits/effects across those boundaries.

We have panic=abort and panic=unwind. How about panic=disallow (could leave the symbol undefined or have compiler magic that directly detects usage of panic!) or panic=halt for these platform targets?

1 Like

A marker trait would probably be enough. There just needs to be compiler magic, so that <Foo as Fn(…)>::call() where Foo: FnNoPanic (and the same for FnMut and FnOnce) is also inferred as #[no_panic] as a refinement.

It would be nice, if that marker trait could also be used with async blocks, e.g.

fn foo() -> impl Future<Output=()> + NoPanic {
  #[no_panic]
  async {
    todo!(); // -> compiler error
  }
}

So IMHO calling the trait NoPanic or PanicFree would be better to allow extending it to async code.

1 Like

Is there a specific reasons it shouldn't be inferred to be NoPanic?

I mean we could talk about how it combines with its closure argument, but does Iterator::map itself do anything that could panic? The source suggests that it does not.

I guess my question is, what happens to a panic!() call (and calls with a similar effect eg calling Result::unwrap) if panic=halt/disallow? Is it silently swallowed? Or is rustc expected to yield a compile error?

.next() can potentially panic for un-Fused Iterators.

2 Likes

This is not actually relevant to effects, but FusedIterator doesn't promise anything about panics. It only promises that next() will not return Some if it has previously returned None, and panicking is not returning.

1 Like

Yes, true. However, my understanding is that .fuse() is typically used to handle iterators that panic if .next() is called after they return None once (IIRC, I/O iterators like file readers, pipe readers, or network streams tend to do this).

The actual doc-comment is

Calling next on a fused iterator that has returned None once is guaranteed to return None again.

I would say that that precludes panicking, it doesn't say that if it returns it returns None again.

3 Likes

Yes, FusedIterator doesn't protect against an iterator panicking before it reaches the end; I don't think any adaptor could do that. What it protects against is "the iterator already ended; why are you asking again" panics. Anyways, Iterator::map can't unconditionally be NoPanic based just on the passed-in F.

I believe the question was intended for clarification of the precise terms. Iterator::map is just the function which constructs a new iterator that maps each element, and could indeed be unconditionally NoPanic, because it just fills in the iterator I and closure F as the fields of std::iter::Map<I,F>, the iterator it returns.

But yes, Map::<I,F>::next could be NoPanic only if both of I::next and F::call_mut are.

2 Likes

How do you plan to handle panic-free code dependent on panic elimination? For example, would be the following function considered panic-free?

fn sum(a: [u32; 2]) -> u64 {
   u64::from(a[0]) + u64::from(a[1])
}

Technically, indexing may panic (though, in this case it gets eliminated even with opt-level=0) and in debug mode without optimizations the addition generates an explicit panic branch.

2 Likes

In my opinion, we shouldn't even try to handle panic elimination like that. To make that function panic-less, you would have to rewrite it to something like this instead:

fn sum([a, b]: [u32; 2]) -> u64 {
   u64::from(a).wrapping_add(u64::from(b))
}

Side note: it would be kinda nice to have a widening_add kinda like overflowing_add but returning the next largest integer type.

5 Likes

I think this shows how much work you need to make something panic-less, even for a very simple function like that.. You even need to hide an error state in your program, not for optimizations, but to intentionally not throw an error in debug mode.

I wonder how much panic-less code you can even write without:

  • hiding errors in your program state (i.e. return dummy/wrong values);
  • hiding representing every panic (even those that can't happen) inside None/Err that will never happen;
  • using unsafe to transform panics into UB.

I feel like what is really needed here is some way to statically prove those panics can't happen, but for this you pretty much need dependent types.

You can just cast to the next bigger integer type and use wrapping_add for that. The problem though is that it becomes harder to compose functions like this since the next widening_add will use an even bigger integer type and so on.

1 Like

The point of panic-free code is that you have to handle all the cases explicitly, so of course, it's more verbose. Writing panic-free code is even more difficult if you can't prove whether it is actually panic-free, that's why I started this discussion thread. Ergonomics are something that can be tackled later, now we should focus on getting something that can be relied on.

What I really want for this is a new Int<MIN, MAX> type, so that we can have Int<A, B> + Int<C, D> → Int<{A+C}, {B+D}>.

With a type like that, (x + 2 * y + z)/4 just works, rather than needing tricky contortions.

2 Likes