SIGSEGV in upcall_stub_load_target when async callbacks are generated with Arena.ofConfined() #366

Closed
opened 2026-05-17 20:47:25 +02:00 by dragon-Elec · 5 comments

I am building a Kotlin desktop application using java-gi for GIO bindings, and I hit a JVM crash (SIGSEGV) when using File.moveAsync with a progress callback.

Here is the Kotlin code that triggers the crash:

val progressCb = FileProgressCallback { current, total, _ ->
    println("Progress: $current / $total")
}
// Dispatched on the GLib main context
GLib.idleAdd(GLib.PRIORITY_DEFAULT) {
    src.moveAsync(dest, FileCopyFlags.NONE, GLib.PRIORITY_DEFAULT, null, progressCb) { _, res, _ ->
        src.moveFinish(res)
    }
    false
}

When this runs, the JVM instantly crashes with a segmentation fault. Here is the raw crash log showing the FFM upcall stub failing when GIO tries to invoke the callback from a background thread:

# A fatal error has been detected by the Java Runtime Environment:
#
#  SIGSEGV (0xb) at pc=0x0000793113b6d328, pid=23167, tid=23267
#
# Problematic frame:
# v  ~StubRoutines::upcall_stub_load_target 0x0000793113b6d328
Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code)
v  ~StubRoutines::upcall_stub_load_target 0x0000793113b6d328
C  [libgio-2.0.so.0+0x6302a]
C  [libglib-2.0.so.0+0x5dd95]  g_main_context_invoke_full+0xe5
C  [libgio-2.0.so.0+0x136dc5]
C  [libgio-2.0.so.0+0x6210d]  g_file_move+0xed

Looking at the generated File.java bindings, the root cause is how the Arena is managed for the callback:

default void moveAsync(..., @Nullable FileProgressCallback progressCallback, ...) {
    try (var _arena = Arena.ofConfined()) {
        // ...
        progressCallback.toCallback(_arena)
        // ...
    } // <-- Arena is closed here!
}

Because moveAsync is an asynchronous function, it returns immediately, closing the _arena. However, the native GIO operation is still running in the background. When it tries to report progress a few milliseconds later via g_main_context_invoke_full, the upcall stub has already been deallocated, resulting in the SIGSEGV.

I understand why the generator does this: the upstream Gio-2.0.gir incorrectly annotates the progress_callback for g_file_move_async with scope="call" (unlike g_file_copy_async which correctly uses scope="notified" and gets a safe Arena.global()).

However, generating a try-with-resources confined arena for any parameter in an _async function seems inherently unsafe, as the function is guaranteed to return before the native operation completes.

Would it be possible for the generator to detect _async functions and force a shared/global arena for their callbacks, regardless of the upstream scope annotation? Or is there another recommended way to handle upstream GIR scope bugs for async functions?

I am building a Kotlin desktop application using java-gi for GIO bindings, and I hit a JVM crash (SIGSEGV) when using File.moveAsync with a progress callback. Here is the Kotlin code that triggers the crash: ```kotlin val progressCb = FileProgressCallback { current, total, _ -> println("Progress: $current / $total") } // Dispatched on the GLib main context GLib.idleAdd(GLib.PRIORITY_DEFAULT) { src.moveAsync(dest, FileCopyFlags.NONE, GLib.PRIORITY_DEFAULT, null, progressCb) { _, res, _ -> src.moveFinish(res) } false } ``` When this runs, the JVM instantly crashes with a segmentation fault. Here is the raw crash log showing the FFM upcall stub failing when GIO tries to invoke the callback from a background thread: ```text # A fatal error has been detected by the Java Runtime Environment: # # SIGSEGV (0xb) at pc=0x0000793113b6d328, pid=23167, tid=23267 # # Problematic frame: # v ~StubRoutines::upcall_stub_load_target 0x0000793113b6d328 Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) v ~StubRoutines::upcall_stub_load_target 0x0000793113b6d328 C [libgio-2.0.so.0+0x6302a] C [libglib-2.0.so.0+0x5dd95] g_main_context_invoke_full+0xe5 C [libgio-2.0.so.0+0x136dc5] C [libgio-2.0.so.0+0x6210d] g_file_move+0xed ``` Looking at the generated `File.java` bindings, the root cause is how the `Arena` is managed for the callback: ```java default void moveAsync(..., @Nullable FileProgressCallback progressCallback, ...) { try (var _arena = Arena.ofConfined()) { // ... progressCallback.toCallback(_arena) // ... } // <-- Arena is closed here! } ``` Because `moveAsync` is an asynchronous function, it returns immediately, closing the `_arena`. However, the native GIO operation is still running in the background. When it tries to report progress a few milliseconds later via `g_main_context_invoke_full`, the upcall stub has already been deallocated, resulting in the SIGSEGV. I understand why the generator does this: the upstream `Gio-2.0.gir` incorrectly annotates the `progress_callback` for `g_file_move_async` with `scope="call"` (unlike `g_file_copy_async` which correctly uses `scope="notified"` and gets a safe `Arena.global()`). However, generating a try-with-resources confined arena for any parameter in an `_async` function seems inherently unsafe, as the function is guaranteed to return before the native operation completes. Would it be possible for the generator to detect `_async` functions and force a shared/global arena for their callbacks, regardless of the upstream scope annotation? Or is there another recommended way to handle upstream GIR scope bugs for async functions?
Owner

Thanks for the issue report!

Would it be possible for the generator to detect _async functions and force a shared/global arena for their callbacks, regardless of the upstream scope annotation? Or is there another recommended way to handle upstream GIR scope bugs for async functions?

A shared or global arena wouldn't work: the shared arena is closed immediately, and the global arena leaks the allocated memory.

My recommendation is to log an issue upstream with GIO. In the meantime I'll try to think of a way to deal with the issue in Java-GI a bit more gracefully than with a segfault.

Thanks for the issue report! > Would it be possible for the generator to detect `_async` functions and force a shared/global arena for their callbacks, regardless of the upstream scope annotation? Or is there another recommended way to handle upstream GIR scope bugs for async functions? A shared or global arena wouldn't work: the shared arena is closed immediately, and the global arena leaks the allocated memory. My recommendation is to log an issue upstream with GIO. In the meantime I'll try to think of a way to deal with the issue in Java-GI a bit more gracefully than with a segfault.
Owner
Relevant: https://gitlab.gnome.org/GNOME/glib/-/work_items/2085
Owner

On second thought, no need to log this upstream. It’s a known issue.

I can work around it in java-gi by using the callback arena for the progressCallback too. Just not sure yet how to best implement it.

On second thought, no need to log this upstream. It’s a known issue. I can work around it in java-gi by using the `callback` arena for the `progressCallback` too. Just not sure yet how to best implement it.
Author

I ended up implementing this — sharing the primary callback's arena for the progress callback. Works on GNOME 46 with File.moveAsync and File.copyAsync.

The idea: if a callback is in an _async function and it's not the primary (not ASYNC scope, no destroy notify), reuse the primary's _scope arena. If the primary is null at runtime, register the CLEANER on the progress callback so the arena still gets closed.

Here's what I changed:

Parameter.java — detect progress callbacks in async functions:

public boolean sharesAsyncCallbackArena() {
    if (!(anyType() instanceof Type type && type.lookup() instanceof Callback))
        return false;
    if (!(parent().parent() instanceof Callable callable && callable.isAsync()))
        return false;
    Scope s = Scope.from(attr("scope"));
    if (s == Scope.ASYNC) return false;
    if (s == Scope.NOTIFIED && destroy() != null) return false;
    return s == Scope.CALL || s == Scope.NOTIFIED;
}

TypedValueGenerator.java — use primary's arena instead of allocating new:

if (v instanceof Parameter p && p.sharesAsyncCallbackArena()) {
    Parameter primary = p.findPrimaryAsyncCallback();
    if (primary != null) {
        arena.add("_$LScope", toJavaIdentifier(primary.name()));
    } else {
        throw new IllegalStateException("Async function has progress callback but no primary callback (malformed GIR)");
    }
} else {
    // existing switch
}

PreprocessingGenerator.java — skip allocation, add CLEANER fallback:

if (p.sharesAsyncCallbackArena()) return; // skip, uses primary's arena

// ... existing ASYNC scope logic ...

// Fallback: if primary is null at runtime, register CLEANER on progress callback
p.parent().parameters().stream()
    .filter(q -> q != p && q.sharesAsyncCallbackArena())
    .findFirst()
    .ifPresent(shared -> {
        builder.addStatement("if ($L == null && $L != null) CLEANER.register(...)");
    });

PostprocessingGenerator.java — skip arena close:

if (v instanceof Parameter p && p.sharesAsyncCallbackArena()) return;

Generated code ends up looking like:

default void moveAsync(..., FileProgressCallback progressCallback, ...) {
    try (var _asyncScope = Arena.ofShared()) {
        progressCallback.toCallback(_asyncScope); // reuses primary's arena
    }
}

I made two additional refinements

  • Changed the fallback to throw IllegalStateException instead of using Arena.global() to prevent memory leaks in the edge case of malformed GIR data
  • Simplified isAsync() to use callableAttrs().finishFunc() != null instead of string heuristic
I ended up implementing this — sharing the primary callback's arena for the progress callback. Works on GNOME 46 with `File.moveAsync` and `File.copyAsync`. The idea: if a callback is in an `_async` function and it's not the primary (not ASYNC scope, no destroy notify), reuse the primary's `_scope` arena. If the primary is null at runtime, register the CLEANER on the progress callback so the arena still gets closed. Here's what I changed: **Parameter.java** — detect progress callbacks in async functions: ```java public boolean sharesAsyncCallbackArena() { if (!(anyType() instanceof Type type && type.lookup() instanceof Callback)) return false; if (!(parent().parent() instanceof Callable callable && callable.isAsync())) return false; Scope s = Scope.from(attr("scope")); if (s == Scope.ASYNC) return false; if (s == Scope.NOTIFIED && destroy() != null) return false; return s == Scope.CALL || s == Scope.NOTIFIED; } ``` **TypedValueGenerator.java** — use primary's arena instead of allocating new: ```java if (v instanceof Parameter p && p.sharesAsyncCallbackArena()) { Parameter primary = p.findPrimaryAsyncCallback(); if (primary != null) { arena.add("_$LScope", toJavaIdentifier(primary.name())); } else { throw new IllegalStateException("Async function has progress callback but no primary callback (malformed GIR)"); } } else { // existing switch } ``` **PreprocessingGenerator.java** — skip allocation, add CLEANER fallback: ```java if (p.sharesAsyncCallbackArena()) return; // skip, uses primary's arena // ... existing ASYNC scope logic ... // Fallback: if primary is null at runtime, register CLEANER on progress callback p.parent().parameters().stream() .filter(q -> q != p && q.sharesAsyncCallbackArena()) .findFirst() .ifPresent(shared -> { builder.addStatement("if ($L == null && $L != null) CLEANER.register(...)"); }); ``` **PostprocessingGenerator.java** — skip arena close: ```java if (v instanceof Parameter p && p.sharesAsyncCallbackArena()) return; ``` Generated code ends up looking like: ```java default void moveAsync(..., FileProgressCallback progressCallback, ...) { try (var _asyncScope = Arena.ofShared()) { progressCallback.toCallback(_asyncScope); // reuses primary's arena } } ``` I made two additional refinements - Changed the fallback to throw `IllegalStateException` instead of using `Arena.global()` to prevent memory leaks in the edge case of malformed GIR data - Simplified `isAsync()` to use `callableAttrs().finishFunc() != null` instead of string heuristic
Owner

Your proposed fix would solve the issue for copyAsync and moveAsync, but I want to also fix the memory leak (the global arena is used in places where the scope = notified).

Basically, this line is a workaround that results in a global arena (scope = forever), and it needs to be removed:

return (scope == Scope.NOTIFIED && destroy() == null) ? Scope.FOREVER : scope;

I've now implemented this properly with !367.

Your proposed fix would solve the issue for `copyAsync` and `moveAsync`, but I want to also fix the memory leak (the global arena is used in places where the scope = notified). Basically, this line is a workaround that results in a global arena (scope = forever), and it needs to be removed: https://codeberg.org/java-gi/java-gi/src/commit/02bbb6ee44ca85d7571f58d0acdf14479112d250/generator/src/main/java/org/javagi/gir/Parameter.java#L157 I've now implemented this properly with !367.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
java-gi/java-gi#366
No description provided.