C-unwind and safety

Note: this question is similar to this one from 2017, but that thread doesn't have a clear answer and I'm hoping for something more informative (hopefully there's more knowledge about this, 8 years later).

At work, I started helping with a Rust project that relies on the C library libpng to deal with images. I'm trying to convince myself we are using the library in a correct way, without triggering UB. I'd love to have a second opinion though, because unsafe Rust is notoriously difficult to get right.

The main challenge is that libpng handles errors using setjmp and longjmp (in a way that resembles throwing and catching exceptions). The user is responsible of using setjmp at the right places, and libpng is responsible for calling longjmp when an error is encountered. In Rust, however, setjmp and longjmp are heavily discouraged (I'm not even certain you can use them at at all without some hackery), so we'd rather not use them.

Fortunately, libpng allows us to configure custom error callbacks. That means that, with the right callbacks, we can skip setjmp / longjmp entirely and rely on Rust's unwinding instead to propagate errors. We currently provide callbacks that trigger an unwind on error, and we make sure to catch the unwind somewhere up the stack. Here's an example:

// Fatal error handler callback
unsafe extern "C-unwind" fn err_handler(_png_ptr: *mut ffi::png_struct, _msg: *const c_char) {
    resume_unwind(Box::new(()));
}

// Read callback (we are reading from memory)
unsafe extern "C-unwind" fn read_handler(png: *mut ffi::png_struct, data: *mut u8, len: usize) {
    let data = slice::from_raw_parts_mut(data.cast(), len);
    let reader = &mut *ffi::png_get_io_ptr(png).cast::<Cursor<&[u8]>>();
    if reader.read_exact(data).is_err() {
        // Unexpected EOF, trigger unwind
        resume_unwind(Box::new(()));
    }
}

// Rust function to decode an image
pub(crate) fn decode_png(data: &[u8]) -> Result<DecodedImage> {
    let result = catch_unwind(|| {
        // Code that calls libpng functions that may unwind
    });
    result.unwrap_or(Err(Error::DecodeFailed))
}

With this in place, everything seems to work properly (fingers crossed). However, people in the Rust community often mention it's unsafe to trigger an unwind from Rust, let it go through a C library and catch it when it arrives back to Rust code (meaning that it is undefined behavior). Because of that, I'm in doubt: how can I be sure what I'm doing satisfies Rust's safety requirements? If it's impossible with the current approach, do you know of any alternative that would guarantee safety?

2 Likes

Yes, it's sound as far as Rust is concerned. C-unwind has been created specifically for C libraries that need panicking to handle errors (like libjpeg and libpng).

I've heard suggestion to also use -fexceptions on the C side just in case C doesn't like unwinding, but I don't think it's necessary.

OTOH, longjmp can't mix with Rust calls, and there's no blessed way to make it work. You have to make C trigger Rust panics instead.

4 Likes

They are straight up undefined behaviour, as they skip Drop during the unwind. So you did the right thing to avoid them.

Could you explain this in more detail? Skipping Drop is safe in general, since mem::forget is safe.

1 Like

Thanks! It's nice to hear, especially since you were the one that asked the original question :slight_smile:

Would you say it's also necessary to use the C-unwind ABI for libpng functions that will not trigger an unwind themselves, but that are using my callbacks under the hood and, therefore, might propagate an unwind from below? For instance, png_read_info currently uses the C ABI in my code, but I'm worried the compiler might assume unwinds are never coming from there and generate code that relies on that assumption.

you are right, skipping the destructors is indeed not the problem.

ironically, because rust move semantic is control flow aware, the real problem setjmp/longjmp could cause is double dropping (not "zero" dropping), which is UB.

UB WARNING: code snippet below is for illustration purpose only

    let mut buf: jmp_buf = [0; 8];
    let a = SomeTypeWithDestructor {};
    if unsafe { setjmp(&mut buf) } != 0 {
        // `a` is dropped for the second time
        std::mem::drop(a);
        // when this branch returns early, the drop happens implicitly
        return; 
    }
    // `a` is dropped for the first time
    std::mem::drop(a);
    unsafe { longjmp(&mut buf, 1) };

you can read this discussion for detailed context:

2 Likes

yes. if calling the function can lead to an unwinding, it doesn't matter the unwinding was triggered at 1 stack frame deep or 100 frames deep, what matters is the boundary between rust functions and foreign functions, which should be declared with an unwinding ABI.

quoting the reference:

Unwinding with the wrong ABI is undefined behavior:

  • Causing an unwind into Rust code from a foreign function that was called via a function declaration or pointer declared with a non-unwinding ABI, such as "C", "system", etc. (For example, this case occurs when such a function written in C++ throws an exception that is uncaught and propagates to Rust.)
  • [...]
2 Likes

Skipping Drop of local variables (i.e. values sitting on the stack) is considered unsound and a bunch of unsafe APIs rely on this (e.g. std::thread::scope).

It's not immediate undefined behaviour but can very easily lead to it especially if you jump across frames not controlled by you.

5 Likes

Yes, everything called from Rust must have C-unwind ABI if there's any chance of it observing unwinding from anywhere.

You don't need to tell Rust about ABI of C functions that are never called from Rust.

(dark humor) So basically the same as longjmp in C, then.

Ah, I see. In other words, forgetting something you remember is fine, but forcing amnesia on someone else is not.

1 Like

This is actually an open question as far as I can tell, as it would affect some optimisations. It might turn out to be UB. See the discussion in:

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.