Skip to content

Fix Android arm64 runtime crash: SIGSEGV in Thread::LinkedList + GC stub calloc - #44

Open
zendrx wants to merge 4 commits into
mainfrom
fix-android-gc-stackbottom
Open

Fix Android arm64 runtime crash: SIGSEGV in Thread::LinkedList + GC stub calloc#44
zendrx wants to merge 4 commits into
mainfrom
fix-android-gc-stackbottom

Conversation

@zendrx

@zendrx zendrx commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Problem

After fixing the GC_stackbottom linker error (PR #42), the app now crashes at runtime on Android arm64 with:

signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x18
 Cause: null pointer dereference
    #00 ... (*Thread::LinkedList(Fiber)#push<Fiber>:Nil+8)
    #01 ... (*Thread::current:Thread+316)
    #02 ... (*Crystal::once ... )
    #03 ... (*Crystal::System::Env::get ... )
    ...
    #11 ... (*Native::App::current ... )  ← NilAssertionError
    #12 ... (crystal_android_main+40)

Root Cause Analysis

This is a chain crash — three separate bugs amplify each other:

Bug 1: Native::App.current is nil on Android

crystal_android_main calls Native::App.current which does @@current.not_nil!, but @@current is never set because the user's main.cr entry point (which calls App.start) doesn't execute on Android. Instead the framework jumps directly to crystal_android_main from a pthread created in native.c.

Bug 2: Crystal's Thread::current crashes on Android bionic libc

When the NilAssertionError from Bug 1 is raised, Crystal's exception handling calls Crystal::System::Env::getCrystal::onceThread::current. Thread::current tries to push a Fiber to the thread's linked list, but Crystal's thread-local storage isn't properly initialised on Android's bionic libc from a non-Crystal pthread → SIGSEGV.

Bug 3: GC stub uses malloc instead of calloc

Boehm GC's GC_malloc zeroes allocated memory, which Crystal relies on for object initialization (unset pointers must be null). The previous GC stub used plain malloc which leaves memory uninitialised, causing random garbage pointer dereferences.

Fixes

1. App-subclass registration (src/native/framework/app.cr)

  • Adds App.registered_subclass / App.registered_subclass= so users can declare their app class from main.cr
  • Adds App.start_registered which starts the registered subclass (used by Android bridge)
  • Backwards-compatible — desktop builds calling App.start() directly still work

2. Android bridge fix (src/native/engine/android/bridge.cr)

  • crystal_android_main now calls App.start_registered instead of App.current
  • Wrapped in begin/rescue with __android_log_print so future errors go to logcat instead of producing an opaque native crash

3. GC stub calloc fix (src/native/cli/build.cr)

  • GC_malloc, GC_malloc_atomic, and GC_malloc_uncollectable now use calloc(1, size) instead of malloc(size) to match Boehm GC's zeroing behaviour
  • GC_realloc zeroes new memory when the pointer moves

4. PCRE2 cross-compile (carried from PR #42)

  • Builds PCRE2 10.42 from source for aarch64-linux-android since Crystal's stdlib regex support requires it

User-facing changes

Your main.cr should now register the app class for Android:

class MyApp < Native::App
  def setup
    # your app setup
  end
end

# On Android: register the subclass (the framework starts it)
# On desktop: start directly as before
{% if flag?(:native_android) %}
  Native::App.registered_subclass = MyApp
{% else %}
  Native::App.start(MyApp)
{% end %}

Gradle version is not modified.

zendrx added 4 commits July 1, 2026 11:40
Crystal's compiler driver adds -lgc to the linker regardless of flags.
The host's x86_64 libgc.a is incompatible with aarch64-linux-android,
causing dlopen to fail at runtime with GC_stackbottom unresolved.

This fix:
1. Builds a minimal no-op libgc.a stub for aarch64-linux-android using
   the NDK toolchain, providing the GC symbols the linker expects
2. Builds PCRE2 from source for aarch64-linux-android (also required by
   Crystal's stdlib and missing for the cross-compile target)
3. Passes -Dwithout_gc to use gc/null.cr instead of gc/boehm.cr, avoiding
   Boehm GC compatibility issues with Android's bionic libc
4. Adds -L flags and -lgc -lpcre2-8 to the final link command

Gradle version is left untouched as requested.
Boehm GC's GC_malloc zeroes allocated memory, which Crystal relies on
for object initialization (unset pointers must be null). The previous
stub used plain malloc which leaves memory uninitialized, causing
random pointer dereferences and SIGSEGV at runtime when Crystal's
runtime tries to use garbage pointer values.

Also: remove -lgc from link flags since -Dwithout_gc already prevents
the compiler driver from emitting it, and the stub is only needed for
linker compatibility when the driver does emit it.

Gradle version is left untouched.
crystal_android_main (the Android C→Crystal bridge) was calling
Native::App.current, but @@current is never set because the user's
main.cr entry point that calls App.start() doesn't run on Android —
instead the framework calls crystal_android_main directly from a
pthread created in native.c.

This adds a registration mechanism:
- App.registered_subclass / App.registered_subclass= lets the user
  declare which App subclass to use from their main.cr
- App.start_registered starts the registered subclass

The user's main.cr should now do:
  Native::App.registered_subclass = MyApp
instead of (or guarded with) App.start(MyApp).

This is backwards-compatible: desktop builds that call App.start()
directly continue to work unchanged.
crystal_android_main was calling Native::App.current which raises a
NilAssertionError because @@current is never set on Android (the
user's main.cr entry point doesn't execute — the framework jumps
directly to crystal_android_main from a pthread).

The NilAssertionError then triggered a cascade crash:
1. Exception raise calls Crystal::System::Env::get
2. Which calls Crystal::once → Thread::current
3. Thread::current tries to push a Fiber to the thread's linked list
4. SIGSEGV because Crystal's thread-local storage isn't properly
   initialised on Android's bionic libc from a non-Crystal pthread

Fix: call Native::App.start_registered which either starts the
subclass the user registered via App.registered_subclass=, or falls
back to an already-set @@current. This avoids the nil assertion and
therefore the entire exception→Thread crash chain.

Also wraps the startup in a begin/rescue with Android log output so
any future runtime errors are visible in logcat instead of producing
an opaque native crash.
zendrx added a commit that referenced this pull request Jul 1, 2026
zendrx added a commit that referenced this pull request Jul 1, 2026
fix-for pr (#44)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant