Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions samples/ExceptionHandler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# ExceptionHandler sample

Shows how to replace the default zkVM fail-fast on a managed `throw` with a
**C# handler that receives the exception object**, prints it, and exits cleanly.

## Background

In the Zisk zkVM build a managed `throw` is lowered by the JIT to
`CORINFO_HELP_THROW`, which calls `RhpThrowEx`. bflat redirects that symbol with
`--wrap=RhpThrowEx` to `__wrap_RhpThrowEx` (in `rhp/module.c`). Normally that
wrapper just fail-fasts.

This sample wires the wrapper to forward the exception object (passed in `a0`)
to a **weak** symbol `ZkvmThrow`. A program that exports one — via
`[UnmanagedCallersOnly(EntryPoint = "ZkvmThrow")]` — takes over the throw and
gets the live `Exception` reference. A program that does not export it links
fine and keeps the plain fail-fast (the reference stays null).

```csharp
[UnmanagedCallersOnly(EntryPoint = "ZkvmThrow")]
static void ZkvmThrow(IntPtr exceptionObj)
{
// The a0 pointer value IS the managed object reference; reinterpret it.
Exception ex = Unsafe.As<IntPtr, Exception>(ref exceptionObj);
Print("[ZkvmThrow] " + ex.GetType() + ": " + ex.Message);
NativeExit(0);
}
```

## Two zkVM-specific rules

1. **Print with `sys_write`, not `Console.WriteLine`.** Under `--libc zisk` the
.NET console path (`SystemNative_Write` / `__stdio_write`) is wrapped to a
no-op, so `Console` output is invisible. `sys_write` (from `libziskos`, the
same call behind `Nethermind.Zkvm.Abstractions.IO.PrintLine`) reaches the
real zkVM stdout. It is only linked when you pass `--extlib`.

2. **Exit with the native `exit()`, not `Environment.Exit`.** The zkVM only
ends on a Zisk exit ecall (`a7 = 93`); `pal`'s `__wrap_exit` emits it.
`Environment.Exit` instead runs the managed runtime shutdown, which — while
an exception is in flight — re-enters the throw path and recurses through the
handler forever.

The runtime side (forwarding in `__wrap_RhpThrowEx`, the `exit` override, and a
no-op `RhpReversePInvoke` so the handler can be entered from the throw path) is
provided by the `rhp` / `pal` modules and the bflat linker wiring.

## Build and run

```console
$ bflat build exhandler.cs --os linux --libc zisk --stdlib dotnet \
--no-pthread --no-pie --no-stacktrace-data \
--extlib https://github.com/NethermindEth/bflat-libziskos:v1.0.0-preview.27
$ ziskemu -e ./exhandler.patched # or run inside Zisk
```

## Expected output

```
before throw
[ZkvmThrow] System.InvalidOperationException: boom from managed code
```

The emulator then completes cleanly (the handler's `NativeExit` issues the Zisk
exit ecall).
64 changes: 64 additions & 0 deletions samples/ExceptionHandler/exhandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Routes a managed `throw` into a C# handler under the Zisk zkVM, prints the
// exception, then exits cleanly.
//
// throw -> CORINFO_HELP_THROW -> RhpThrowEx (exception object in a0)
// -> --wrap=RhpThrowEx -> __wrap_RhpThrowEx (rhp/module.c)
// -> ZkvmThrow (this method)
//
// The C wrapper forwards the exception object (a0) to a weak symbol named
// `ZkvmThrow`. A program that exports one (via [UnmanagedCallersOnly]) takes
// over the throw; a program that does not falls back to a plain fail-fast.
//
// Two zkVM-specific details make this work end to end:
//
// * Print with sys_write, NOT Console.WriteLine. Under --libc zisk the .NET
// console path (SystemNative_Write / __stdio_write) is wrapped to a no-op,
// so its output is invisible. sys_write (from libziskos, the same call
// behind Nethermind.Zkvm.Abstractions IO.PrintLine) reaches the real zkVM
// stdout. It is only available with --extlib bflat-libziskos.
//
// * Terminate with the native exit() (a direct Zisk `a7=93; ecall`), NOT
// Environment.Exit. Environment.Exit runs the managed runtime shutdown
// which, while an exception is in flight, re-enters the throw path and
// recurses through this handler forever.

using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;

class Program
{
// Statically-linked libc exit; --wrap=exit -> pal __wrap_exit -> a7=93 ecall.
[DllImport("*", EntryPoint = "exit")]
static extern void NativeExit(int code);

// zkVM raw stdout write (from libziskos, linked via --extlib).
[DllImport("*", EntryPoint = "sys_write")]
static extern unsafe void sys_write(uint fd, byte* ptr, nuint nbytes);

static unsafe void Print(string s)
{
byte[] bytes = Encoding.UTF8.GetBytes(s + "\n");
fixed (byte* p = bytes)
sys_write(1u, p, (nuint)bytes.Length);
}

[UnmanagedCallersOnly(EntryPoint = "ZkvmThrow")]
static void ZkvmThrow(IntPtr exceptionObj)
{
// The a0 pointer value IS the managed object reference; reinterpret it.
Exception ex = Unsafe.As<IntPtr, Exception>(ref exceptionObj);

Print("[ZkvmThrow] " + ex.GetType().ToString() + ": " + ex.Message);

// The handler owns the termination decision (here: clean exit).
NativeExit(0);
}

static int Main()
{
Print("before throw");
throw new InvalidOperationException("boom from managed code");
}
}
12 changes: 12 additions & 0 deletions src/bflat/BuildCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1527,6 +1527,12 @@ or TargetArchitecture.RiscV64
ldArgs.Append($"--wrap=RhNewString ");
ldArgs.Append($"--wrap=RhpPInvoke ");
ldArgs.Append($"--wrap=RhpPInvokeReturn ");
/* No-op the reverse P/Invoke transition: the real one parks the
* thread at a GC-safe point, which deadlocks when a managed
* exception handler is entered from __wrap_RhpThrowEx (thread
* already cooperative, single-threaded zkVM never rendezvous). */
ldArgs.Append($"--wrap=RhpReversePInvoke ");
ldArgs.Append($"--wrap=RhpReversePInvokeReturn ");
ldArgs.Append($"--wrap=RhBulkMoveWithWriteBarrier ");
ldArgs.Append($"--wrap=S_P_CoreLib_System_Runtime_TypeCast__CheckCastAny ");
ldArgs.Append($"--wrap=S_P_CoreLib_System_Diagnostics_Tracing_EventPipeEventProvider__Register ");
Expand Down Expand Up @@ -1607,6 +1613,12 @@ or TargetArchitecture.RiscV64
ldArgs.Append($"--wrap=signal ");
ldArgs.Append($"--wrap=syscall ");
ldArgs.Append($"--wrap=sysconf ");
/* musl exit()/_Exit()/abort() issue exit_group (syscall 94),
* which ZisK does not treat as program end. Redirect them to
* pal's __wrap_* which emit the real ZisK exit ecall (a7=93). */
ldArgs.Append($"--wrap=exit ");
ldArgs.Append($"--wrap=_Exit ");
ldArgs.Append($"--wrap=abort ");
if (libc == "zisk")
{
/* Hide write() in Zisk */
Expand Down
37 changes: 37 additions & 0 deletions src/bflat/modules/pal/module.c
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,43 @@ __wrap_syscall(long number, ...)
}
}

/* Clean zkVM termination. ZisK only treats an ecall with a7 == 93
* (CAUSE_EXIT) as "program end": its trap handler routes that to ROM_EXIT,
* whose instruction carries the `end` flag the emulator waits for. musl's
* exit()/_Exit() issue exit_group (94) instead, which ZisK does NOT recognise,
* so the emulation stops "not completed". Override musl's terminators (via
* --wrap=exit/_Exit/abort) to emit the real ZisK exit ecall. */
__attribute__((noreturn))
static void
zkvm_raw_exit(long code)
{
register long a0 __asm__("a0") = code;
register long a7 __asm__("a7") = 93; /* ZisK CAUSE_EXIT */
__asm__ volatile("ecall" : : "r"(a0), "r"(a7) : "memory");
for (;;) { } /* ecall ends the program; loop is just in case */
}

__attribute__((noreturn))
void
__wrap_exit(int code)
{
zkvm_raw_exit(code);
}

__attribute__((noreturn))
void
__wrap__Exit(int code)
{
zkvm_raw_exit(code);
}

__attribute__((noreturn))
void
__wrap_abort(void)
{
zkvm_raw_exit(134); /* 128 + SIGABRT, conventional abort exit code */
}

int RhIsGCBridgeActive(void)
{
return 0;
Expand Down
3 changes: 3 additions & 0 deletions src/bflat/modules/pal/module_params.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ options:
- value: --wrap=munlock
- value: --wrap=mlockall
- value: --wrap=munlockall
- value: --wrap=exit
- value: --wrap=_Exit
- value: --wrap=abort
39 changes: 37 additions & 2 deletions src/bflat/modules/rhp/module.c
Original file line number Diff line number Diff line change
Expand Up @@ -490,12 +490,47 @@ void *__wrap_S_P_CoreLib_System_Number__UInt32ToDecStrForKnownSmallNumber(int va
return S_P_CoreLib_System_Number__UInt32ToDecStr_NoSmallNumberCheck(value);
}

void __wrap_RhpThrowEx(void)
/* Reverse P/Invoke transition. The real CoreLib RhpReversePInvoke attaches the
* thread and parks it at a GC-safe point (AttachOrTrapThread2). That only makes
* sense for a native->managed boundary entered in preemptive mode. When a
* managed exception handler (an [UnmanagedCallersOnly] method like ZkvmThrow)
* is entered from __wrap_RhpThrowEx, the thread is ALREADY cooperative, so the
* real transition spins on a GC rendezvous that never comes in the
* single-threaded, never-collecting zkVM. No-op it (matches zerolib). */
void
__wrap_RhpReversePInvoke(void *pFrame)
{
(void)pFrame;
}

void
__wrap_RhpReversePInvokeReturn(void *pFrame)
{
(void)pFrame;
}

/* RhpThrowEx receives the managed exception object in a0 (first arg register).
* Instead of a blind fail-fast, hand that object to a managed handler that the
* user program may export as [UnmanagedCallersOnly(EntryPoint = "ZkvmThrow")].
* The reference is weak: programs that don't define ZkvmThrow link fine and
* fall back to exit(1), so existing binaries keep their old behaviour. A
* program that does define it takes full control of the throw — the wrapper
* does not exit, so the handler decides what happens next. */
extern void ZkvmThrow(void *exceptionObj) __attribute__((weak));

void __wrap_RhpThrowEx(void *exceptionObj)
{
if (ZkvmThrow != NULL)
{
ZkvmThrow(exceptionObj);
return;
}
exit(1);
}

/* FailFast carries a message string (or null), not an exception object, so it
* keeps the plain fail-fast path rather than routing through ZkvmThrow. */
void __wrap_S_P_CoreLib_System_RuntimeExceptionHelpers__FailFast(void)
{
__wrap_RhpThrowEx();
exit(1);
}
2 changes: 2 additions & 0 deletions src/bflat/modules/rhp/module_params.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ options:
- value: --wrap=RhNewString
- value: --wrap=RhpPInvoke
- value: --wrap=RhpPInvokeReturn
- value: --wrap=RhpReversePInvoke
- value: --wrap=RhpReversePInvokeReturn
- value: --wrap=RhBulkMoveWithWriteBarrier
- value: --wrap=S_P_CoreLib_System_Runtime_TypeCast__CheckCastAny
- value: --wrap=S_P_CoreLib_System_Threading_Lock__get_IsHeldByCurrentThread
Expand Down
Loading