Repository navigation
Description
Description
Starting in .NET 9, pinvoking Lua's luaL_error can raise a SEHException.
I believe this is due to luaL_error being a relatively thin wrapper over longjmp.
I narrowed this down to the newly defaulted on improved exception handling in .NET 9.
Note that while I use KeraLua in my repro, the same thing happens if you pinvoke Lua directly - KeraLua is just a convenient way to package up a pre-built Lua 5.4 binary, and eliminate some of my own pinvoke definition from the repro.
Reproduction Steps
csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="KeraLua" Version="1.4.1" />
</ItemGroup>
</Project>Program.cs
using KeraLua;
namespace LongJumpRepro
{
public class Program
{
private const string ErrorMsg = "!!RAISED!!";
private static Lua? lua;
public static void Main(string[] args)
{
// This works as expected in .NET 8
// It raises a SEHException in .NET 9
// The exception in .NET 9 goes away if you set environment variable DOTNET_LegacyExceptionHandling=1
Console.WriteLine($".NET version: {Environment.Version}");
Console.WriteLine($"DOTNET_LegacyExceptionHandling: {Environment.GetEnvironmentVariable("DOTNET_LegacyExceptionHandling")}");
try
{
lua = new Lua();
lua.PushCFunction(RaiseLuaError);
var status = lua.PCall(0, 0, 0);
Console.WriteLine($"Status (Expected {LuaStatus.ErrRun}): {status}");
var errMsg = lua.CheckString(1);
Console.WriteLine($"Error Message (Expected '{ErrorMsg}'): {ErrorMsg}");
}
finally
{
lua?.Dispose();
}
}
private static int RaiseLuaError(nint luaState)
=> lua!.Error(ErrorMsg);
}
}Expected behavior
Expected behavior is either the .NET 8:

or .NET 9 with DOTNET_LegacyExceptionHandling=1 behavior:

Where luaL_error works when pinvoked.
Actual behavior
Regression?
Yes, this worked in earlier versions of .NET. I have confirmed it worked in .NET 8, and this was reported when first running the code on .NET 9.
Known Workarounds
Setting DOTNET_LegacyExceptionHandling=1 fixes the issue in .NET 9.
Discussion on the PR that introduced it suggests it is temporary however, if it is removed in a later .NET release we will no longer have a workaround.
Configuration
- .NET 8 and .NET 9
- Windows 11
- x64
- I have not tested on other configurations
- I am not using Blazor
Other information
While I admit longjmp is weird, embedding Lua is pretty popular.

Activity
nazar554 commentedon Jan 9, 2025
Rust
1.24.1release notes describe a similar issuehttps://blog.rust-lang.org/2018/03/01/Rust-1.24.1.html#do-not-abort-when-unwinding-through-ffi
rust-lang/rust#48572
janvorli commentedon Jan 9, 2025
The problem is interesting. At the point .NET gets notified about the error raised by Lua, the call stack looks as shown below. The key point is that there is a block of Lua frames, then a block of .NET managed frames, then again a block of Lua frames and then again .NET managed frames.
.NET gets notified (the
ProcessCLRExceptionis called) when the SEH stack walk enters the frame 16. TheEXCEPTION_RECORDthat it gets as an argument has exception code 0x80000026, which is what Lua passed in and that code meansSTATUS_LONGJUMP. Since that's not .NET exception, we create a .NET exception, we create theSystem.Runtime.InteropServices.SEHExceptionfor it and propagate it through the .NET frames 16, 17, 18 and 19. Since there is nothing to catch that exception in those frames, the stack walk reaches the lua frame (1a). When we reach a native frame and we know there are some more .NET managed frames towards the bottom of the stack, we rethrow the exception we were propagating through the .NET frames, which is theSEHException, from the context of the frame 1a. But its exception code is obviously not theSTATUS_LONGJUMPand so it is not caught by the Lua code and ends up being unhandled.I need to figure out how to preserve the exception code to rethrow and then rethrow the original SEH exception.
janvorli commentedon Jan 9, 2025
It is even more interesting. Re-raising the same SEH exception as .NET got notified about doesn't help for this case, because longjmp doesn't actually use RaiseException. So we will need to re-issue the longjmp, extracting the arguments from the
EXCEPTION_RECORD. Or alternatively, maybe ignore theSTATUS_LONGJUMPin the new EH. I have to check how the old EH behaves w.r.t. processing theSTATUS_LONGJUMPthat skips over managed frames.janvorli commentedon Jan 9, 2025
So we cannot ignore it, the old EH also doesn't ignore it, so there can be e.g. finally handlers invoked while the longjmp jumps over managed frames. I've experimented with re-running longjmp instead of doing RaiseException when the exception escapes managed frames and is propagated into native ones and it seems to work correctly.
jkotas commentedon Jan 9, 2025
Using longjump to skip over managed frames had all sort of issues, even in the old EH scheme. We have it explicitly documented as unsupported in https://learn.microsoft.com/en-us/dotnet/standard/native-interop/exceptions-interoperability#setjmplongjmp-behaviors
janvorli commentedon Jan 10, 2025
Ok, then I guess that fixing this specific issue by making it just skip over the managed frames without invoking finallys is the right way to do it. That would be also aligned with Unix where we could not even see there is a longjmp over managed frames.
Make longjmp over managed frames work
jkotas commentedon Jan 10, 2025
On Unix, longjump does not work at all since we are not able to unwind transition frames. It means that the key runtime data structures get corrupted and you are very likely going to crash soon after. #1445 has an example of the crash that it leads to.
Make longjmp over managed frames work (dotnet#111259)