A blog of sorts

Getting Rust and the JVM to talk to each other without JNI

Or using 4 languages to talk between 2

Overview

Some time ago I started building a tafl board game engine in Rust. I got it far enough to do 2-player games on the command line, but typing your moves is kinda clunky; I never actually finished a game over the CLI interface! Did I even code the win condition checks? I don’t remember. To give the engine a real GUI I initially looked to the Web, Rust has plenty of libraries in this space, but I personally have mostly done Web UI with static-ish HTML and CSS. I initially learned to use JavaScript to ‘enhance’ an already usable HTML+CSS webpage, not the JavaScript first world a lot of extremely dynamic content websites use today. What I am familiar with is Jetpack Compose, which I had only used on Android but fortunately JetBrains have got this working on the desktop and to quote:

Compose for Desktop simplifies and accelerates UI development for desktop applications, and allows extensive UI code sharing between Android and desktop applications.

For me, being able to use the same APIs I’ve already learnt to build GUIs with on Android can’t really be topped in terms of developer productivity.

All that was left to do was get Kotlin calling the Rust code I’d already started and I knew I could built a UI that was efficient enough for me to actually play a full game. Now, Java has had JNI for calling native code for longer than Rust has existed, but there’s something newer I wanted to try out.

Using the Java Foreign Function and Memory API

The JEP contains lots of details on this Java API, but I want to focus on the Rust side and the code I’ve actually written in this article rather than the libraries and tooling. To summarise, I want to write Rust code that knows about the game engine, and not about Java (in theory no changes would be needed to use this engine somewhere else like in an iOS app). I can settle for writing a C API in Rust manually since for a game engine all I really need to send over the FFI layer to the JVM is a bunch of numbers. Then I can use the crate cbindgen to autogenerate the C header for that C API I’ve written in Rust. On the JVM side, I don’t want to write more glue code consuming this C API.

Fortunately, there’s a tool called jextract which can consume that C header API and generate Java bindings for me. Problem solved right?

Yes and no. The aforementioned libraries create the full loop, from Rust Engine → C header → jextract & Java Foreign Function and Memory API → Kotlin Jetpack Compse UI, but they don’t tell me how to fill in the details for actually passing data along.

The build process I’ve constructed so far leaves a lot to be desired too, ideally I would only need to press Run in the IDE and it’ll build the Rust library as a dependency of the app, and then automatically run jextract without requiring a manual installation before finally compiling the Kotlin code. Doing this properly is left as an exercise to the reader - but if you do get this working please tell me, I’d love to pipeline this all properly in Gradle.

Giving the JVM an object

The first step to getting a JVM app to play a tafl game surely has to start with the Rust code allocating structs for the game engine, then handing off control of that data to the JVM. That’ll let me create a new game each time I need to, no static/global variables needed or wanted.

This little bit of Rust code (GameState being my tafl game engine instance)

mod state;

use state::GameState;

#[derive(Debug)]
pub struct GameStateHandle {
    state: GameState,
}

impl GameStateHandle {
    fn new() -> Self {
        GameStateHandle {
            state: GameState::default(),
        }
    }
}

/// Creates a new GameStateHandle
#[no_mangle]
pub extern fn game_state_handle_new() -> *mut GameStateHandle {
    let boxed = Box::new(GameStateHandle::new());
    // let the caller be responsible for managing this memory now
    Box::into_raw(boxed)
}

Generates this short C header

typedef struct GameStateHandle GameStateHandle;

/**
 * Creates a new GameStateHandle
 */
struct GameStateHandle *game_state_handle_new(void);

And absolutely loads of Java glue code I barely understand but can clearly see the constructor in.

// bindings_h.java
// Generated by jextract

package io.github.skeletonxf.bindings;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.VarHandle;
import java.nio.ByteOrder;
import java.lang.foreign.*;
import static java.lang.foreign.ValueLayout.*;
public class bindings_h  {

    /* package-private */ bindings_h() {}
    public static OfByte C_CHAR = Constants$root.C_CHAR$LAYOUT;
    public static OfShort C_SHORT = Constants$root.C_SHORT$LAYOUT;
    public static OfInt C_INT = Constants$root.C_INT$LAYOUT;
    public static OfLong C_LONG = Constants$root.C_LONG_LONG$LAYOUT;
    public static OfLong C_LONG_LONG = Constants$root.C_LONG_LONG$LAYOUT;
    public static OfFloat C_FLOAT = Constants$root.C_FLOAT$LAYOUT;
    public static OfDouble C_DOUBLE = Constants$root.C_DOUBLE$LAYOUT;
    public static OfAddress C_POINTER = Constants$root.C_POINTER$LAYOUT;
    public static MethodHandle game_state_handle_new$MH() {
        return RuntimeHelper.requireNonNull(constants$0.game_state_handle_new$MH,"game_state_handle_new");
    }
    public static MemoryAddress game_state_handle_new () {
        var mh$ = game_state_handle_new$MH();
        try {
            return (java.lang.foreign.MemoryAddress)mh$.invokeExact();
        } catch (Throwable ex$) {
            throw new AssertionError("should not reach here", ex$);
        }
    }
}
// constants$0.java
// Generated by jextract

package io.github.skeletonxf.bindings;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.VarHandle;
import java.nio.ByteOrder;
import java.lang.foreign.*;
import static java.lang.foreign.ValueLayout.*;
class constants$0 {

    static final FunctionDescriptor game_state_handle_new$FUNC = FunctionDescriptor.of(Constants$root.C_POINTER$LAYOUT);
    static final MethodHandle game_state_handle_new$MH = RuntimeHelper.downcallHandle(
        "game_state_handle_new",
        constants$0.game_state_handle_new$FUNC
    );
}
// Constants$root.java
// Generated by jextract

package io.github.skeletonxf.bindings;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.VarHandle;
import java.nio.ByteOrder;
import java.lang.foreign.*;
import static java.lang.foreign.ValueLayout.*;
public class Constants$root {

    static final  OfBoolean C_BOOL$LAYOUT = JAVA_BOOLEAN;
    static final  OfByte C_CHAR$LAYOUT = JAVA_BYTE;
    static final  OfShort C_SHORT$LAYOUT = JAVA_SHORT.withBitAlignment(16);
    static final  OfInt C_INT$LAYOUT = JAVA_INT.withBitAlignment(32);
    static final  OfLong C_LONG$LAYOUT = JAVA_LONG.withBitAlignment(64);
    static final  OfLong C_LONG_LONG$LAYOUT = JAVA_LONG.withBitAlignment(64);
    static final  OfFloat C_FLOAT$LAYOUT = JAVA_FLOAT.withBitAlignment(32);
    static final  OfDouble C_DOUBLE$LAYOUT = JAVA_DOUBLE.withBitAlignment(64);
    static final  OfAddress C_POINTER$LAYOUT = ADDRESS.withBitAlignment(64);
}
// RuntimeHelper.java
package io.github.skeletonxf.bindings;
// Generated by jextract

import java.lang.foreign.Addressable;
import java.lang.foreign.Linker;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.GroupLayout;
import java.lang.foreign.SymbolLookup;
import java.lang.foreign.MemoryAddress;
import java.lang.foreign.MemoryLayout;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.MemorySession;
import java.lang.foreign.SegmentAllocator;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.io.File;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Optional;
import java.util.stream.Stream;

import static java.lang.foreign.Linker.*;
import static java.lang.foreign.ValueLayout.*;

final class RuntimeHelper {

    private RuntimeHelper() {}
    private final static Linker LINKER = Linker.nativeLinker();
    private final static ClassLoader LOADER = RuntimeHelper.class.getClassLoader();
    private final static MethodHandles.Lookup MH_LOOKUP = MethodHandles.lookup();
    private final static SymbolLookup SYMBOL_LOOKUP;

    final static SegmentAllocator CONSTANT_ALLOCATOR =
            (size, align) -> MemorySegment.allocateNative(size, align, MemorySession.openImplicit());

    static {
        System.load("/home/skeletonxf/Documents/Rust/hnefatafl/target/debug/libhnefatafl.so");
        SymbolLookup loaderLookup = SymbolLookup.loaderLookup();
        SYMBOL_LOOKUP = name -> loaderLookup.lookup(name).or(() -> LINKER.defaultLookup().lookup(name));
    }

    static <T> T requireNonNull(T obj, String symbolName) {
        if (obj == null) {
            throw new UnsatisfiedLinkError("unresolved symbol: " + symbolName);
        }
        return obj;
    }

    private final static SegmentAllocator THROWING_ALLOCATOR = (x, y) -> { throw new AssertionError("should not reach here"); };

    static final MemorySegment lookupGlobalVariable(String name, MemoryLayout layout) {
        return SYMBOL_LOOKUP.lookup(name).map(symbol -> MemorySegment.ofAddress(symbol.address(), layout.byteSize(), MemorySession.openShared())).orElse(null);
    }

    static final MethodHandle downcallHandle(String name, FunctionDescriptor fdesc) {
        return SYMBOL_LOOKUP.lookup(name).
                map(addr -> LINKER.downcallHandle(addr, fdesc)).
                orElse(null);
    }

    static final MethodHandle downcallHandle(FunctionDescriptor fdesc) {
        return LINKER.downcallHandle(fdesc);
    }

    static final MethodHandle downcallHandleVariadic(String name, FunctionDescriptor fdesc) {
        return SYMBOL_LOOKUP.lookup(name).
                map(addr -> VarargsInvoker.make(addr, fdesc)).
                orElse(null);
    }

    static final <Z> MemorySegment upcallStub(Class<Z> fi, Z z, FunctionDescriptor fdesc, MemorySession session) {
        try {
            MethodHandle handle = MH_LOOKUP.findVirtual(fi, "apply", Linker.upcallType(fdesc));
            handle = handle.bindTo(z);
            return LINKER.upcallStub(handle, fdesc, session);
        } catch (Throwable ex) {
            throw new AssertionError(ex);
        }
    }

    static MemorySegment asArray(MemoryAddress addr, MemoryLayout layout, int numElements, MemorySession session) {
         return MemorySegment.ofAddress(addr, numElements * layout.byteSize(), session);
    }

    // Internals only below this point

    private static class VarargsInvoker {
        private static final MethodHandle INVOKE_MH;
        private final MemorySegment symbol;
        private final FunctionDescriptor function;

        private VarargsInvoker(MemorySegment symbol, FunctionDescriptor function) {
            this.symbol = symbol;
            this.function = function;
        }

        static {
            try {
                INVOKE_MH = MethodHandles.lookup().findVirtual(VarargsInvoker.class, "invoke", MethodType.methodType(Object.class, SegmentAllocator.class, Object[].class));
            } catch (ReflectiveOperationException e) {
                throw new RuntimeException(e);
            }
        }

        static MethodHandle make(MemorySegment symbol, FunctionDescriptor function) {
            VarargsInvoker invoker = new VarargsInvoker(symbol, function);
            MethodHandle handle = INVOKE_MH.bindTo(invoker).asCollector(Object[].class, function.argumentLayouts().size() + 1);
            MethodType mtype = MethodType.methodType(function.returnLayout().isPresent() ? carrier(function.returnLayout().get(), true) : void.class);
            for (MemoryLayout layout : function.argumentLayouts()) {
                mtype = mtype.appendParameterTypes(carrier(layout, false));
            }
            mtype = mtype.appendParameterTypes(Object[].class);
            if (mtype.returnType().equals(MemorySegment.class)) {
                mtype = mtype.insertParameterTypes(0, SegmentAllocator.class);
            } else {
                handle = MethodHandles.insertArguments(handle, 0, THROWING_ALLOCATOR);
            }
            return handle.asType(mtype);
        }

        static Class<?> carrier(MemoryLayout layout, boolean ret) {
            if (layout instanceof ValueLayout valueLayout) {
                return (ret || valueLayout.carrier() != MemoryAddress.class) ?
                        valueLayout.carrier() : Addressable.class;
            } else if (layout instanceof GroupLayout) {
                return MemorySegment.class;
            } else {
                throw new AssertionError("Cannot get here!");
            }
        }

        private Object invoke(SegmentAllocator allocator, Object[] args) throws Throwable {
            // one trailing Object[]
            int nNamedArgs = function.argumentLayouts().size();
            assert(args.length == nNamedArgs + 1);
            // The last argument is the array of vararg collector
            Object[] unnamedArgs = (Object[]) args[args.length - 1];

            int argsCount = nNamedArgs + unnamedArgs.length;
            Class<?>[] argTypes = new Class<?>[argsCount];
            MemoryLayout[] argLayouts = new MemoryLayout[nNamedArgs + unnamedArgs.length];

            int pos = 0;
            for (pos = 0; pos < nNamedArgs; pos++) {
                argLayouts[pos] = function.argumentLayouts().get(pos);
            }

            assert pos == nNamedArgs;
            for (Object o: unnamedArgs) {
                argLayouts[pos] = variadicLayout(normalize(o.getClass()));
                pos++;
            }
            assert pos == argsCount;

            FunctionDescriptor f = (function.returnLayout().isEmpty()) ?
                    FunctionDescriptor.ofVoid(argLayouts) :
                    FunctionDescriptor.of(function.returnLayout().get(), argLayouts);
            MethodHandle mh = LINKER.downcallHandle(symbol, f);
            if (mh.type().returnType() == MemorySegment.class) {
                mh = mh.bindTo(allocator);
            }
            // flatten argument list so that it can be passed to an asSpreader MH
            Object[] allArgs = new Object[nNamedArgs + unnamedArgs.length];
            System.arraycopy(args, 0, allArgs, 0, nNamedArgs);
            System.arraycopy(unnamedArgs, 0, allArgs, nNamedArgs, unnamedArgs.length);

            return mh.asSpreader(Object[].class, argsCount).invoke(allArgs);
        }

        private static Class<?> unboxIfNeeded(Class<?> clazz) {
            if (clazz == Boolean.class) {
                return boolean.class;
            } else if (clazz == Void.class) {
                return void.class;
            } else if (clazz == Byte.class) {
                return byte.class;
            } else if (clazz == Character.class) {
                return char.class;
            } else if (clazz == Short.class) {
                return short.class;
            } else if (clazz == Integer.class) {
                return int.class;
            } else if (clazz == Long.class) {
                return long.class;
            } else if (clazz == Float.class) {
                return float.class;
            } else if (clazz == Double.class) {
                return double.class;
            } else {
                return clazz;
            }
        }

        private Class<?> promote(Class<?> c) {
            if (c == byte.class || c == char.class || c == short.class || c == int.class) {
                return long.class;
            } else if (c == float.class) {
                return double.class;
            } else {
                return c;
            }
        }

        private Class<?> normalize(Class<?> c) {
            c = unboxIfNeeded(c);
            if (c.isPrimitive()) {
                return promote(c);
            }
            if (MemoryAddress.class.isAssignableFrom(c)) {
                return MemoryAddress.class;
            }
            if (MemorySegment.class.isAssignableFrom(c)) {
                return MemorySegment.class;
            }
            throw new IllegalArgumentException("Invalid type for ABI: " + c.getTypeName());
        }

        private MemoryLayout variadicLayout(Class<?> c) {
            if (c == long.class) {
                return JAVA_LONG;
            } else if (c == double.class) {
                return JAVA_DOUBLE;
            } else if (MemoryAddress.class.isAssignableFrom(c)) {
                return ADDRESS;
            } else {
                throw new IllegalArgumentException("Unhandled variadic argument class: " + c);
            }
        }
    }
}

Looks like I can just call bindings_h.game_state_handle_new() and that invokes the Rust code and gives me a java.lang.foreign.MemoryAddress referencing the data the Rust code allocated that is my game state (handle). So far so good.

Doing things with the GameStateHandle pointer safely

The GameStateHandle pointer the constructor returns is completely useless without methods for the JVM app to call. However, this is where the challenges start, because Rust has leaked the GameStateHandle it allocated on the heap and let the JVM manage it. Therefore, for the JVM app to invoke a method on this pointer, Rust has to cast the pointer back to a Rust reference.

Rust has pervasive rules about references. You can have one mutable, exclusive reference, or many immutable, shared references. This can be concisely put as ‘Aliasing XOR Mutability’. Unlike Java and Kotlin, where an immutable list of elements may only be shallowly immutable (you can still mutate items inside an immutable list), this mutability in Rust is deep. A &Vec<MyMutableStruct> doesn’t let you call methods of MyMutableStruct that require &mut references.

Java, and Kotlin, do not enforce ‘Aliasing XOR Mutability’, they don’t care how many references you have to something, you can try to mutate it from 10 different threads all at once even if the docs on the class say it’s not thread safe. Of course, there are Java and Kotlin APIs for mutating things from different threads in a ‘safe’ way, and we want to achieve the same thing here.

To summarise, this kind of innocuous (and not thread safe) class in Kotlin takes a whole book to explain how to do safely in Rust.

data class DoubleLinkedList(
    var parent: DoubleLinkedList?,
    var child: DoubleLinkedList?,
)

I initially wrote a method like this:

#[derive(Clone, Debug)]
enum FFIError {
    NullPointer,
    Panic,
}

unsafe fn with_handle<F, R>(handle: *mut GameStateHandle, op: F) -> Result<R, FFIError>
where
    F: FnOnce(&mut GameStateHandle) -> R + std::panic::UnwindSafe,
{
    if handle.is_null() {
        return Err(FFIError::NullPointer)
    }
    std::panic::catch_unwind(|| {
        // SAFETY: We only give out valid pointers, and are trusting that
        //  the Kotlin code does not invalidate them.
        let handle = unsafe {
            &mut *handle
        };
        op(handle)
    }).map_err(|_| FFIError::Panic)
}

/// Prints the game state
#[no_mangle]
pub unsafe extern fn game_state_handle_debug(handle: *mut GameStateHandle) {
    if let Err(error) = with_handle(handle, |handle| {
        println!("Game state handle:\n{:?}", handle);
    }) {
        eprint!("Error calling game_state_handle_debug: {:?}", error);
    }
}
// autogenerated C header
void game_state_handle_debug(const struct GameStateHandle *handle);

Since C-unwind isn’t stable at the time of writing, we can’t yet write (stable) code that intentionally unwinds past the Rust FFI boundary. Catching any bugs in the Rust code before returning to Kotlin ensures we can return something sensible in such a case.

Update: C-unwind was stabilised in Rust 1.71 and stable code now can unwind past the Rust FFI boundary, however we have no particular reason to here, there’s not going to be more Rust below some of the JVM stack that would catch the panic.

Although the with_handle encapsulates a lot of the FFI boilerplate for the debug method, there’s still the issue that nothing is stopping us from calling game_state_handle_debug at the same time on two different threads. That would be an aliased and mutable reference, and then

let handle = unsafe {
    &mut *handle
};

will create two &mut references to the GameStateHandle, which is undefined behaviour. I could just promise not to do that from the Kotlin side, but there’s no compiler checks on the Kotlin side, so it’s not ideal. Kotlin could also create a wrong pointer and call the game_state_handle_debug method, which would also be undefined behaviour, but nothing can really be done about that and that’s at least much harder to do by accident.

After some research, I eventually came across a solution. The same way one would rewrite that DoubleLinkedList in Kotlin to add in thread safety can be used on the Rust side.

data class DoubleLinkedList(
    private var parent: DoubleLinkedList?,
    private var child: DoubleLinkedList?,
    private val mutex: Mutex,
) {
    // methods and implementation not included, the important bit is all mutation
    // is done through the critical section of the mutex's lock, which ensures
    // modifications to the linked list are properly synchronised across multiple
    // threads
}

Adding in a Mutex to the GameStateHandle lets us tweak with_handle so that it doesn’t construct a &mut GameStateHandle.

#[derive(Debug)]
pub struct GameStateHandle {
    state: Mutex<GameState>,
}

/// Takes an (optionally) aliased handle to the game state, unlocks the mutex and performs
/// and operation with a non aliased mutable reference to the game state, returning the
/// result of the operation or an error if there was a failure with the FFI.
fn with_handle<F, R>(handle: *const GameStateHandle, op: F) -> Result<R, FFIError>
where
    F: FnOnce(&mut GameState) -> R + std::panic::UnwindSafe,
{
    if handle.is_null() {
        return Err(FFIError::NullPointer)
    }
    std::panic::catch_unwind(|| {
        // SAFETY: We only give out valid pointers, and are trusting that
        //  the Kotlin code does not invalidate them.
        let handle = unsafe {
            & *handle
        };
        // Since the Kotlin side can freely alias as much as it likes, we put
        // the aliased handle around a Mutex so we can ensure no aliasing for
        // the actual game state
        let mut guard = match handle.state.lock() {
            Ok(guard) => guard,
            Err(poison_error) => {
                eprintln!("Poisoned mutex: {}", poison_error);
                poison_error.into_inner()
            },
        };
        op(&mut guard)
        // drop mutex guard
    }).map_err(|_| FFIError::Panic)
}

/// Prints the game state
#[no_mangle]
pub extern fn game_state_handle_debug(handle: *const GameStateHandle) {
    if let Err(error) = with_handle(handle, |handle| {
        println!("Game state handle:\n{:?}", handle);
    }) {
        eprint!("Error calling game_state_handle_debug: {:?}", error);
    }
}

Now, if two Kotlin threads call game_state_handle_debug at the same time,

let handle = unsafe {
    & *handle
};

will construct two shared references to the GameStateHandle (which is safe because they’re not mutable), and one of them will wait on acquiring the mutex guard that gives access to a &mut GameState.

There might be less heavy handed solutions than full blown synchronisation with a Mutex, but for a type I’m not intending to have a lot of access from multiple threads at once, the overhead should be pretty minor.

Unfortunately, while this works great for methods, at some point I also want to destroy the game state so I can start a new game.

Cleaning up

The Rust side of this is straightforward just like for the constructor, the challenge is again enforcing Aliasing XOR Mutability.

/// Destroys the data owned by the pointer
/// The caller is responsible for ensuring there are no aliased references
/// elsewhere in the program
#[no_mangle]
pub unsafe extern fn game_state_handle_destroy(handle: *mut GameStateHandle) {
    if handle.is_null() {
        return;
    }
    std::mem::drop(unsafe {
        Box::from_raw(handle)
    });
    std::mem::drop(Box::from_raw(handle));
}
// autogenerated C header
void game_state_handle_destroy(struct GameStateHandle *handle);

To reclaim the memory from the JVM and drop the GameStateHandle, we need a *mut GameStateHandle pointer. There’s two related undefined behaviour landmines here, double frees and freeing the GameStateHandle while still holding references to it.

I don’t think there is much I can do about these issues on the Rust side, so we’ll leave Rust with game_state_handle_destroy as an unsafe function and go look at what we can do on the Kotlin side.

interface GameState {
    fun debug()
}

class Bridge {
    fun useHandle() {
        GameStateHandle().use { it.debug() }
    }

    class GameStateHandle private constructor(
        private val handle: MemoryAddress
    ): Closeable, GameState {
        constructor() : this(bindings_h.game_state_handle_new())

        override fun debug() {
            bindings_h.game_state_handle_debug(handle)
        }

        override fun close() {
            bindings_h.game_state_handle_destroy(handle)
        }
    }
}

I initially wrote a Kotlin wrapper around the jextract Java bindings like this. For very shortlived data, Closeable works pretty well, use lets you do operations with a GameStateHandle and close gets called automatically for you once you’re done. Since game_state_handle_destroy is called for you, you’re not going to trigger a double free accidentally. However, I don’t want such a shortlived GameStateHandle. I want it to last for the duration of a game, which is going to be far longer than a single function call.

Some initial searching lead me to the deprecated finalize method. It seemed like exactly what I needed, something that runs exactly once as the object is going to be garbage collected. If GameStateHandle is getting garbage collected, there’s no other aliases to handle: MemoryAddress because we never gave any out and GameStateHandle is the only one still holding it.

Still, it looked like it was deprecated for good reason, so I followed the deprecation notice and looked the Cleaner class docs.

class GameStateHandle: GameState {
    private val handle: MemoryAddress = bindings_h.game_state_handle_new()

    init {
        // We must not use any inner classes or lambdas for the runnable object,
        // to avoid capturing our GameStateHandle instance, which would prevent
        // the cleaner ever running.
        // We could hold onto the cleanable this method returns so that we can
        // manually trigger it with a `close()` method or such, but such an API
        // can't stop us calling that method while still holding references to
        // the GameStateHandle, in which case we'd trigger undefined behaviour
        // and likely reclaim the memory on the Rust side while we still have
        // other aliases to it that think it's still in use. Instead, the
        // *only* way to tell Rust it's time to call the destructor is when the
        // cleaner determines there are no more references to our
        // GameStateHandle.
        bridgeCleaner.register(this, BridgeHandleCleaner(handle))
    }

    override fun debug() {
        bindings_h.game_state_handle_debug(handle)
    }

    companion object {
        private val bridgeCleaner: Cleaner = Cleaner.create()
    }
}

private data class BridgeHandleCleaner(private val handle: MemoryAddress): Runnable {
    override fun run() {
        // Because this class is private, and we only ever call it from the
        // cleaner, and we never give out any references to our
        // `handle: MemoryAddress` to any other classes, this runs exactly once
        // after all references to GameStateHandle are dead and the cleaner runs
        // us. Hence, we can meet the requirement that the handle is not
        // aliased, so the Rust side can use it as an exclusive reference and
        // reclaim the memory safely.
        bindings_h.game_state_handle_destroy(handle)
    }
}

Following the javadoc’s example, I arrived at this. BridgeHandleCleaner is private, so only GameStateHandle can instantiate it. When GameStateHandle is instantiated, we add a BridgeHandleCleaner to the singleton bridgeCleaner: Cleaner. I’ve removed the Closeable implementation because I want the Cleaner to be the only means of running the BridgeHandleCleaner, which it does exactly once, when there are no references to the GameStateHandle instance anymore. A destructor in all but name.

If there are no references to GameStateHandle, since handle is private to GameStateHandle and BridgeHandleCleaner, that means we will always call game_state_handle_destroy with the only reference left, which is then completely safe to be a mutable one.

Now all I need to do is build that GUI on top of that GameState interface I set out to do…