Packages
mob
0.7.19
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
android/jni/mob_beam.zig
//! mob_beam.zig — Mob BEAM launcher and JNI bridge initialisation (Android).
//!
//! Phase 6b iter 2 of the build-system migration: Zig port of the original
//! mob_beam.c. Behaviour is intentionally byte-for-byte equivalent — every
//! load-bearing comment in the C version (cold-start race fix, SELinux exec
//! rules, Play Store split-APK fallback, exqlite/pythonx priv-dir symlinks)
//! is preserved verbatim because future maintainers will hit the same
//! constraints and need the same explanations in front of them.
//!
//! The FFI surface (JNI vtable, libc, Android log, dlfcn, pthreads) lives in
//! mob_zig.zig — see that file's header for why we hand-declare it (Zig
//! 0.17-dev's @cImport is gone and `zig translate-c` hangs on the NDK's
//! jni.h).
//!
//! Symbols defined elsewhere in the link:
//! * mob_nif.c provides `_mob_ui_cache_class_impl`,
//! `_mob_bridge_init_activity`,
//! `mob_set_startup_phase`,
//! `mob_set_startup_error`,
//! `g_jvm`, `g_activity`.
//! * libbeam.a provides `erl_start`.
const std = @import("std");
const jni = @import("mob_zig.zig");
const build_options = @import("build_options");
// ── Comptime build flags ──────────────────────────────────────────────────
// Compile-time knobs threaded in by build.zig via `b.addOptions()`.
//
// * `no_beam` — Config A: baseline measurement. BEAM never launched, the
// activity stays a stock Android shell. Used for battery benchmarks.
// * `beam_flags_mode` — picks the default scheduler-tuning argv shape:
// - "untuned": no flags (stock Erlang defaults)
// - "sbwt_only": only -sbwt none / -sbwtdcpu / -sbwtdio (cuts the
// scheduler-busy-wait idle drain; lightest tuning)
// - "nerves_full": full Nerves-style tuning (-S 1:1 -SDcpu 1:1 ...)
// (default)
//
// The runtime override (beams_dir/mob_beam_flags) supersedes either default.
const NO_BEAM: bool = build_options.no_beam;
const BEAM_FLAGS_MODE: []const u8 = build_options.beam_flags_mode;
// ── Logging ───────────────────────────────────────────────────────────────
const LOG_TAG: [*:0]const u8 = "MobBeam";
inline fn logi(comptime fmt: []const u8, args: anytype) void {
jni.logWrite(jni.ANDROID_LOG_INFO, LOG_TAG, fmt, args);
}
inline fn loge(comptime fmt: []const u8, args: anytype) void {
jni.logWrite(jni.ANDROID_LOG_ERROR, LOG_TAG, fmt, args);
}
inline fn lastErrno() [*:0]const u8 {
return jni.strerror(jni.__errno().*);
}
// ── Externs from mob_nif.c ────────────────────────────────────────────────
// Forward declarations for symbols that live next to us in the final .so.
// These are defined by mob_nif.c (kept C in iter 2 — port in a later iter).
extern fn _mob_ui_cache_class_impl(env: *jni.JNIEnv, bridge_class: [*:0]const u8) callconv(.c) void;
extern fn _mob_bridge_init_activity(env: *jni.JNIEnv, activity: jni.JObject) callconv(.c) void;
extern fn mob_set_startup_phase(phase: [*:0]const u8) callconv(.c) void;
extern fn mob_set_startup_error(err: [*:0]const u8) callconv(.c) void;
// Global JVM pointer + Activity global ref. Defined in mob_nif.c, populated
// from JNI_OnLoad / mob_init_bridge. Both may be null until those run.
extern var g_jvm: ?*jni.JavaVM;
extern var g_activity: jni.JObject;
// ── Extern from libbeam.a ─────────────────────────────────────────────────
// BEAM entry point. erl_start blocks forever in the normal case; returning
// is an unexpected-exit condition we report and let the OS reap the process.
extern fn erl_start(argc: c_int, argv: [*]const ?[*:0]const u8) callconv(.c) void;
// ── Constants ─────────────────────────────────────────────────────────────
const ERTS_VSN: []const u8 = "erts-17.0";
// ── Module-level state ────────────────────────────────────────────────────
// Populated in mob_init_bridge, read by mob_start_beam. Sized generously
// so paths under /data/data/<package>/files/... never truncate.
var s_native_lib_dir: [512]u8 = @splat(0);
var s_files_dir: [512]u8 = @splat(0);
// Runtime BEAM flag override loaded from beams_dir/mob_beam_flags.
// In-place tokenised (NULs replace whitespace), pointers indexed into the
// buffer. Same shape as the C version.
var s_flags_buf: [512]u8 = @splat(0);
var s_runtime_flags: [64]?[*:0]const u8 = @splat(null);
var s_runtime_flag_count: usize = 0;
// ── Small helpers ─────────────────────────────────────────────────────────
/// Format `fmt`/`args` into `buf`, NUL-terminating the result. Returns a
/// `[*:0]const u8` view of the buffer. Mirrors `snprintf(buf, sizeof(buf), ...)`.
fn formatZ(buf: []u8, comptime fmt: []const u8, args: anytype) [*:0]const u8 {
std.debug.assert(buf.len > 0);
const slice = std.fmt.bufPrint(buf, fmt, args) catch buf[0 .. buf.len - 1];
const end = @min(slice.len, buf.len - 1);
buf[end] = 0;
return @ptrCast(buf.ptr);
}
inline fn isWhitespace(c: u8) bool {
return c == ' ' or c == '\t' or c == '\n' or c == '\r';
}
// ── BEAM stdout/stderr → logcat ──────────────────────────────────────────
// Without this, anything the BEAM writes to stderr (including ** crash
// reports from Logger and the boot script's :application.start/2 errors)
// is silently dropped on Android. Wire stdout + stderr to a pipe and read
// them on a detached thread, emitting each line under the "BEAMout" tag.
// One-shot: called once from mob_init_bridge before any BEAM code runs.
//
// See beam_crash.md (Incident #1) for the case that motivated this.
fn mobBeamLogReader(arg: ?*anyopaque) callconv(.c) ?*anyopaque {
const fd: c_int = @intCast(@intFromPtr(arg));
var buf: [4096]u8 = undefined;
var line: [4096]u8 = undefined;
var line_pos: usize = 0;
while (true) {
const n = jni.read(fd, &buf, buf.len);
if (n <= 0) break;
const got: usize = @intCast(n);
var i: usize = 0;
while (i < got) : (i += 1) {
const c = buf[i];
if (c == '\n' or line_pos >= line.len - 1) {
line[line_pos] = 0;
if (line_pos > 0) {
const cstr: [*:0]const u8 = @ptrCast(&line);
_ = jni.__android_log_write(jni.ANDROID_LOG_INFO, "BEAMout", cstr);
}
line_pos = 0;
} else if (c != '\r') {
line[line_pos] = c;
line_pos += 1;
}
}
}
return null;
}
fn mobCaptureBeamStdio() void {
var pipe_fds: [2]c_int = undefined;
if (jni.pipe(&pipe_fds) != 0) {
loge("mob_capture_beam_stdio: pipe() failed: {s}", .{lastErrno()});
return;
}
if (jni.dup2(pipe_fds[1], jni.STDOUT_FILENO) < 0) {
loge("mob_capture_beam_stdio: dup2 stdout failed: {s}", .{lastErrno()});
}
if (jni.dup2(pipe_fds[1], jni.STDERR_FILENO) < 0) {
loge("mob_capture_beam_stdio: dup2 stderr failed: {s}", .{lastErrno()});
}
_ = jni.close(pipe_fds[1]);
var tid: jni.PthreadT = 0;
const arg: ?*anyopaque = @ptrFromInt(@as(usize, @intCast(pipe_fds[0])));
if (jni.pthread_create(&tid, null, mobBeamLogReader, arg) != 0) {
loge("mob_capture_beam_stdio: pthread_create failed: {s}", .{lastErrno()});
_ = jni.close(pipe_fds[0]);
return;
}
_ = jni.pthread_detach(tid);
// Disable buffering so output reaches the pipe immediately, not on
// exit (which we never reach for a long-running BEAM).
_ = jni.setvbuf(jni.stdout, null, jni._IONBF, 0);
_ = jni.setvbuf(jni.stderr, null, jni._IONBF, 0);
logi("mob_capture_beam_stdio: piping stdout/stderr to logcat (tag: BEAMout)", .{});
}
// ── Public entry points ───────────────────────────────────────────────────
export fn mob_ui_cache_class(env: *jni.JNIEnv, bridge_class: [*:0]const u8) callconv(.c) void {
_mob_ui_cache_class_impl(env, bridge_class);
}
export fn mob_init_bridge(env: *jni.JNIEnv, activity: jni.JObject) callconv(.c) void {
// Capture BEAM stdio first so any startup errors (NIF load failures,
// application:start/2 crashes) land in logcat instead of /dev/null.
mobCaptureBeamStdio();
const activity_global = jni.newGlobalRef(env, activity);
g_activity = activity_global;
_mob_bridge_init_activity(env, activity_global);
// Get nativeLibraryDir so mob_start_beam can symlink ERTS executables there.
// Files in the native lib dir carry the apk_data_file SELinux label which
// allows execve() from untrusted_app, unlike files in app_data_file.
const ctx_cls = jni.findClass(env, "android/content/Context");
const get_app_info = jni.getMethodID(env, ctx_cls, "getApplicationInfo", "()Landroid/content/pm/ApplicationInfo;");
const app_info = jni.callObjectMethod(env, activity, get_app_info);
const app_info_cls = jni.findClass(env, "android/content/pm/ApplicationInfo");
const fid = jni.getFieldID(env, app_info_cls, "nativeLibraryDir", "Ljava/lang/String;");
const jdir = jni.getObjectField(env, app_info, fid);
if (jni.getStringUTFChars(env, jdir)) |dir| {
jni.copyZ(&s_native_lib_dir, dir);
jni.releaseStringUTFChars(env, jdir, dir);
}
logi("mob_init_bridge: native lib dir = {s}", .{jni.asCStr(&s_native_lib_dir)});
// Get filesDir for OTP root path (app-specific, avoids hardcoding package name).
const get_files_dir = jni.getMethodID(env, ctx_cls, "getFilesDir", "()Ljava/io/File;");
const files_dir_obj = jni.callObjectMethod(env, activity, get_files_dir);
const file_cls = jni.findClass(env, "java/io/File");
const get_path = jni.getMethodID(env, file_cls, "getPath", "()Ljava/lang/String;");
const jfiles_path = jni.callObjectMethod(env, files_dir_obj, get_path);
if (jni.getStringUTFChars(env, jfiles_path)) |fp| {
jni.copyZ(&s_files_dir, fp);
jni.releaseStringUTFChars(env, jfiles_path, fp);
}
logi("mob_init_bridge: files dir = {s}", .{jni.asCStr(&s_files_dir)});
}
export fn mob_start_beam(app_module: [*:0]const u8) callconv(.c) void {
if (NO_BEAM) {
// Config A: baseline measurement — stock Android activity, BEAM never launched.
logi("mob_start_beam: NO_BEAM defined, skipping BEAM launch (battery baseline)", .{});
return;
}
// Re-dlopen ourselves with RTLD_GLOBAL so the BEAM's enif_* symbols
// (statically linked into this library) are visible when the BEAM
// later dlopens a NIF library (e.g. crypto.so). Without this, Android
// loads libpigeon.so with RTLD_LOCAL by default, hiding enif_* from
// dlopen'd children — crypto.so on_load fails with
// `cannot locate symbol enif_get_tuple`.
{
var self_path_buf: [600]u8 = undefined;
const self_path = formatZ(&self_path_buf, "{s}/lib{s}.so", .{
jni.asCStr(&s_native_lib_dir),
app_module,
});
if (jni.dlopen(self_path, jni.RTLD_NOW | jni.RTLD_GLOBAL) == null) {
const err: [*:0]const u8 = jni.dlerror() orelse "unknown";
loge("mob_start_beam: dlopen self with RTLD_GLOBAL failed: {s}", .{err});
} else {
logi("mob_start_beam: re-dlopened self RTLD_GLOBAL: {s}", .{self_path});
}
}
mob_set_startup_phase("Setting up BEAM environment…");
// Build all paths dynamically from s_files_dir (set in mob_init_bridge).
var otp_root_buf: [560]u8 = undefined;
const otp_root = formatZ(&otp_root_buf, "{s}/otp", .{jni.asCStr(&s_files_dir)});
var bindir_buf: [600]u8 = undefined;
const bindir = formatZ(&bindir_buf, "{s}/{s}/bin", .{ otp_root, ERTS_VSN });
var beams_dir_buf: [600]u8 = undefined;
const beams_dir = formatZ(&beams_dir_buf, "{s}/{s}", .{ otp_root, app_module });
var elixir_dir_buf: [600]u8 = undefined;
const elixir_dir = formatZ(&elixir_dir_buf, "{s}/lib/elixir/ebin", .{otp_root});
var logger_dir_buf: [600]u8 = undefined;
const logger_dir = formatZ(&logger_dir_buf, "{s}/lib/logger/ebin", .{otp_root});
var eex_dir_buf: [600]u8 = undefined;
const eex_dir = formatZ(&eex_dir_buf, "{s}/lib/eex/ebin", .{otp_root});
var crash_dump_buf: [560]u8 = undefined;
const crash_dump = formatZ(&crash_dump_buf, "{s}/erl_crash.dump", .{jni.asCStr(&s_files_dir)});
_ = jni.setenv("BINDIR", bindir, 1);
_ = jni.setenv("ROOTDIR", otp_root, 1);
_ = jni.setenv("PROGNAME", "erl", 1);
_ = jni.setenv("EMU", "beam", 1);
_ = jni.setenv("HOME", jni.asCStr(&s_files_dir), 1);
_ = jni.setenv("MOB_DATA_DIR", jni.asCStr(&s_files_dir), 1);
// MOB_BEAMS_DIR — the directory where app BEAMs (and priv/) are deployed.
//
// Problem: Ecto.Migrator uses :code.priv_dir(app) to locate migration .exs
// files. :code.priv_dir/1 works by looking up the app's OTP lib structure
// ($OTP_ROOT/lib/APP-VERSION/ebin/). Mob apps are deployed to a flat -pa
// directory (e.g. files/otp/my_app/*.beam), not an OTP lib structure, so
// :code.priv_dir/1 returns {error, bad_name} and Ecto silently reports
// "Migrations already up" without running anything.
//
// Fix: deployer.ex pushes priv/ alongside the BEAMs into beams_dir/priv/.
// App code reads MOB_BEAMS_DIR at startup and passes the explicit path to
// Ecto.Migrator.run/4 instead of relying on :code.priv_dir/1. This env var
// is the only reliable way to communicate beams_dir to Elixir code since it
// is computed here from getFilesDir() at runtime (the path includes the
// Android user ID which is not predictable at compile time).
_ = jni.setenv("MOB_BEAMS_DIR", beams_dir, 1);
_ = jni.setenv("ERL_CRASH_DUMP", crash_dump, 1);
_ = jni.setenv("ERL_CRASH_DUMP_SECONDS", "30", 1);
// MOB_NATIVE_LIB_DIR — the app's nativeLibraryDir (apk_data_file context,
// exec allowed). Apps that bundle extra binaries (escript, rebar3, etc.)
// as `lib<name>.so` in jniLibs/<abi>/ can find them here at runtime —
// their paths include the APK install hash and aren't predictable at
// compile time. Empty when launched from a split APK that didn't extract
// .so files; callers should fall back to BINDIR in that case.
if (s_native_lib_dir[0] != 0) {
_ = jni.setenv("MOB_NATIVE_LIB_DIR", jni.asCStr(&s_native_lib_dir), 1);
}
// RUSTLER_BEAM_LIBRARY_PATH — tells rustler where the .so containing it
// (libpigeon.so in Mob's static-link model) lives, so its
// DlsymNifFiller can dlopen(path, RTLD_NOW | RTLD_NOLOAD) directly
// instead of dlopen(NULL). On Bionic, dlopen(NULL) returns the app
// process namespace which misses sibling .so's exported symbols even
// when System.loadLibrary'd with RTLD_GLOBAL — see filmor's comment on
// rusterlium/rustler#726. dladdr on a function we know is in this .so
// (mob_start_beam itself) gives us dli_fname = the absolute load path.
{
var info = std.mem.zeroes(jni.DlInfo);
const probe: *const anyopaque = @ptrCast(&mob_start_beam);
if (jni.dladdr(probe, &info) != 0) {
if (info.dli_fname) |fname| {
_ = jni.setenv("RUSTLER_BEAM_LIBRARY_PATH", fname, 1);
_ = jni.__android_log_print(
jni.ANDROID_LOG_INFO,
"MobBeam",
"RUSTLER_BEAM_LIBRARY_PATH=%s",
fname,
);
}
}
}
var eval_expr_buf: [280]u8 = undefined;
const eval_expr = formatZ(&eval_expr_buf, "{s}:start().", .{app_module});
// Compile-time default BEAM tuning flags. Selected by build_options.beam_flags_mode
// (untuned / sbwt_only / nerves_full). Runtime override below wins if present.
const default_flags: []const [*:0]const u8 = comptime selectDefaultFlags();
// Runtime override: read whitespace-separated flags from beams_dir/mob_beam_flags.
// Written by `mix mob.deploy --schedulers N` or `--beam-flags "..."`.
{
var flags_path_buf: [640]u8 = undefined;
const flags_path = formatZ(&flags_path_buf, "{s}/mob_beam_flags", .{beams_dir});
if (jni.fopen(flags_path, "r")) |fp| {
const n_read = jni.fread(&s_flags_buf, 1, s_flags_buf.len - 1, fp);
_ = jni.fclose(fp);
s_flags_buf[n_read] = 0;
s_runtime_flag_count = 0;
var p: usize = 0;
while (p < n_read and s_runtime_flag_count < 63) {
while (p < n_read and isWhitespace(s_flags_buf[p])) : (p += 1) {}
if (p >= n_read or s_flags_buf[p] == 0) break;
s_runtime_flags[s_runtime_flag_count] = @ptrCast(&s_flags_buf[p]);
s_runtime_flag_count += 1;
while (p < n_read and !isWhitespace(s_flags_buf[p]) and s_flags_buf[p] != 0) : (p += 1) {}
if (p < n_read) {
s_flags_buf[p] = 0;
p += 1;
}
}
s_runtime_flags[s_runtime_flag_count] = null;
logi("mob_start_beam: loaded {d} runtime flags from {s}", .{ s_runtime_flag_count, flags_path });
}
}
var boot_path_buf: [580]u8 = undefined;
const boot_path = formatZ(&boot_path_buf, "{s}/releases/29/start_clean", .{otp_root});
var args: [128]?[*:0]const u8 = @splat(null);
var ac: usize = 0;
args[ac] = "beam";
ac += 1;
if (s_runtime_flag_count > 0) {
var i: usize = 0;
while (i < s_runtime_flag_count) : (i += 1) {
args[ac] = s_runtime_flags[i];
ac += 1;
}
} else {
for (default_flags) |f| {
args[ac] = f;
ac += 1;
}
}
args[ac] = "--";
ac += 1;
args[ac] = "-root";
ac += 1;
args[ac] = otp_root;
ac += 1;
args[ac] = "-bindir";
ac += 1;
args[ac] = bindir;
ac += 1;
args[ac] = "-progname";
ac += 1;
args[ac] = "erl";
ac += 1;
args[ac] = "--";
ac += 1;
args[ac] = "-noshell";
ac += 1;
args[ac] = "-noinput";
ac += 1;
args[ac] = "-boot";
ac += 1;
args[ac] = boot_path;
ac += 1;
args[ac] = "-pa";
ac += 1;
args[ac] = elixir_dir;
ac += 1;
args[ac] = "-pa";
ac += 1;
args[ac] = logger_dir;
ac += 1;
args[ac] = "-pa";
ac += 1;
args[ac] = eex_dir;
ac += 1;
args[ac] = "-pa";
ac += 1;
args[ac] = beams_dir;
ac += 1;
args[ac] = "-eval";
ac += 1;
args[ac] = eval_expr;
ac += 1;
args[ac] = null;
// ── Cold-start race condition fix ────────────────────────────────────────
//
// DO NOT REMOVE THIS BLOCK.
//
// Problem: on a cold start (first launch after install or after the process
// was killed), calling erl_start() too early causes a SIGABRT deep inside
// ERTS pthread initialisation. The crash looks like:
//
// FORTIFY: pthread_mutex_lock called on a destroyed mutex
// backtrace:
// #00 abort
// #01 pthread_mutex_lock (FORTIFY wrapper)
// #02 ... (ERTS internal thread pool setup)
// #03 erl_start
//
// Root cause: Android's hwui (hardware-accelerated UI renderer) creates its
// own native thread pool during the very first layout/draw pass. That
// initialisation uses pthread mutexes that it allocates and later destroys.
// ERTS also calls into pthreads during erl_start(). If erl_start() runs
// concurrently with hwui's first-draw setup, the two pthread paths race on
// the same internal libc state and the FORTIFY mutex check fires → SIGABRT.
//
// The race only reproduces on cold start because:
// • On warm start hwui's thread pool already exists → no race.
// • The window-focus event is the earliest point at which Android
// guarantees the first layout/draw pass has completed, so hwui's
// pthread state is stable.
//
// Fix: poll Activity.hasWindowFocus() every 50 ms before calling erl_start().
// hasWindowFocus() returns true only after the window has been drawn and
// given input focus, which is *after* hwui finishes its thread-pool setup.
// We wait up to 3 seconds (covers slow emulators and heavily loaded devices)
// and fall through anyway so a stuck window never blocks BEAM forever.
//
// Why this lives here instead of in MainActivity.kt:
// Putting the delay in Kotlin would mean every app built on Mob needs to
// replicate and maintain the fix. Centralising it in mob_beam.zig means
// app code can stay a simple `Thread({ nativeStartBeam() }).start()`.
//
// JNI threading notes:
// • beam-main is created via `new Thread()` in Kotlin, so it is already
// attached to the JVM when this function runs. Calling
// AttachCurrentThread on an already-attached thread is a no-op, but
// calling DetachCurrentThread on a Java-created thread makes ART abort.
// • We therefore call GetEnv first. If the thread is already attached
// (needs_detach == 0) we skip both Attach and Detach. Only a purely
// native thread that was never attached would set needs_detach == 1.
if (g_jvm) |jvm| {
if (g_activity != null) {
mob_set_startup_phase("Waiting for window focus…");
const existing = jni.getEnv(jvm, jni.JNI_VERSION_1_6);
const needs_detach = existing == null;
const env2_maybe: ?*jni.JNIEnv = existing orelse jni.attachCurrentThread(jvm);
if (env2_maybe) |env2| {
const act_cls = jni.getObjectClass(env2, g_activity);
const has_focus = jni.getMethodID(env2, act_cls, "hasWindowFocus", "()Z");
var waited: i32 = 0;
const max_wait: i32 = 3000; // ms — fall through if focus never arrives
while (jni.callBooleanMethod(env2, g_activity, has_focus) == 0 and waited < max_wait) {
const ts = jni.Timespec{ .tv_sec = 0, .tv_nsec = 50_000_000 }; // 50 ms
_ = jni.nanosleep(&ts, null);
waited += 50;
}
// Only detach if we attached above — detaching a Java thread aborts ART.
if (needs_detach) jni.detachCurrentThread(jvm);
if (waited >= max_wait) {
logi("mob_start_beam: focus timeout ({d} ms) — starting BEAM anyway", .{waited});
} else if (waited > 0) {
logi("mob_start_beam: waited {d} ms for window focus", .{waited});
}
} else {
loge("mob_start_beam: AttachCurrentThread failed — skipping focus wait", .{});
}
}
}
// ── end cold-start race condition fix ────────────────────────────────────
mob_set_startup_phase("Starting BEAM…");
logi("mob_start_beam: starting BEAM with module={s}, argc={d}", .{ app_module, ac });
// Symlink ERTS executables from BINDIR to the native lib dir.
//
// When installed via `adb install`, nativeLibraryDir contains the .so files
// and the symlink approach works (apk_data_file SELinux label allows execve).
//
// When installed via Play Store (split APKs), Android does NOT extract .so
// files to nativeLibraryDir on modern devices — they stay inside the split APK
// zip. In that case MobBridge.extractBeamHelpersFromSplitApk() copies the
// binaries directly into erts/bin/ before this point. We detect that scenario
// by checking whether the nativeLibDir target exists: if it doesn't, skip the
// unlink+symlink so we don't clobber the already-extracted real file.
if (s_native_lib_dir[0] != 0) {
const exes = [_][*:0]const u8{ "erl_child_setup", "inet_gethost", "epmd" };
const libs = [_][*:0]const u8{ "liberl_child_setup.so", "libinet_gethost.so", "libepmd.so" };
var i: usize = 0;
while (i < exes.len) : (i += 1) {
var bin_path_buf: [512]u8 = undefined;
var lib_path_buf: [512]u8 = undefined;
const bin_path = formatZ(&bin_path_buf, "{s}/{s}/bin/{s}", .{ otp_root, ERTS_VSN, exes[i] });
const lib_path = formatZ(&lib_path_buf, "{s}/{s}", .{ jni.asCStr(&s_native_lib_dir), libs[i] });
var st: jni.Stat = undefined;
if (jni.stat(lib_path, &st) == 0) {
// nativeLibDir has the file (adb install) — use symlink
_ = jni.unlink(bin_path);
if (jni.symlink(lib_path, bin_path) == 0) {
logi("mob_start_beam: symlink {s} -> {s}", .{ exes[i], lib_path });
} else {
loge("mob_start_beam: symlink {s} failed: {s}", .{ exes[i], lastErrno() });
}
} else {
// nativeLibDir empty (Play Store split APK) — MobBridge should have
// extracted the binary directly to bin_path; leave it in place.
var st_bin: jni.Stat = undefined;
if (jni.stat(bin_path, &st_bin) == 0) {
logi("mob_start_beam: symlink {s} (extracted from split APK)", .{exes[i]});
} else {
loge("mob_start_beam: symlink {s} missing from both nativeLibDir and bin/", .{exes[i]});
}
}
}
}
// Optional ERTS extras: symlink iff the app shipped them in jniLibs.
// Silently skip otherwise — these aren't required for BEAM boot, but apps
// that want them (e.g. Mix.install of a rebar3-built dep needs `escript`
// *and* a spawnable `erl` / `erlexec` for the escript runner to bootstrap
// a fresh VM) can drop `lib<name>.so` into android/app/src/main/jniLibs/<abi>/
// to get a working BINDIR/<name>. `erl` and `erlexec` both target the
// same library because they're the same binary — erlexec doesn't switch
// on argv[0].
if (s_native_lib_dir[0] != 0) {
const opt_exes = [_][*:0]const u8{ "escript", "erlexec", "erl", "beam.smp" };
const opt_libs = [_][*:0]const u8{ "libescript.so", "liberlexec.so", "liberlexec.so", "libbeam_smp.so" };
var j: usize = 0;
while (j < opt_exes.len) : (j += 1) {
var bin_path_buf: [512]u8 = undefined;
var lib_path_buf: [512]u8 = undefined;
const bin_path = formatZ(&bin_path_buf, "{s}/{s}/bin/{s}", .{ otp_root, ERTS_VSN, opt_exes[j] });
const lib_path = formatZ(&lib_path_buf, "{s}/{s}", .{ jni.asCStr(&s_native_lib_dir), opt_libs[j] });
var st: jni.Stat = undefined;
if (jni.stat(lib_path, &st) == 0) {
_ = jni.unlink(bin_path);
if (jni.symlink(lib_path, bin_path) == 0) {
logi("mob_start_beam: symlink {s} -> {s} (optional)", .{ opt_exes[j], lib_path });
} else {
loge("mob_start_beam: symlink {s} failed: {s}", .{ opt_exes[j], lastErrno() });
}
}
// No lib in nativeLibDir => app didn't ask for this extra. Skip
// silently — don't log; not an error.
}
}
// Symlink sqlite3_nif.so into the exqlite OTP lib structure so that
// code:priv_dir(:exqlite) resolves correctly.
//
// The OTP code server registers lib_dirs by scanning $OTP_ROOT/lib/*/ebin
// at boot. For code:lib_dir(:exqlite) to work, exqlite must live at
// $OTP_ROOT/lib/exqlite-VERSION/ — a flat -pa dir is NOT sufficient.
// The deployer creates $OTP_ROOT/lib/exqlite-VERSION/{ebin,priv}; we
// create the sqlite3_nif.so symlink inside priv/ at runtime so the path
// (which contains the APK install hash) is always up-to-date.
if (s_native_lib_dir[0] != 0) {
var nif_target_buf: [560]u8 = undefined;
const nif_target = formatZ(&nif_target_buf, "{s}/libsqlite3_nif.so", .{jni.asCStr(&s_native_lib_dir)});
// Scan $OTP_ROOT/lib/ for exqlite-* and symlink the NIF in its priv/.
var lib_path_buf: [600]u8 = undefined;
const lib_path = formatZ(&lib_path_buf, "{s}/lib", .{otp_root});
var found = false;
if (jni.opendir(lib_path)) |d| {
while (jni.readdir(d)) |entry| {
if (jni.strncmp(@ptrCast(&entry.d_name), "exqlite-", 8) == 0) {
var exqlite_priv_buf: [700]u8 = undefined;
const d_name_c: [*:0]const u8 = @ptrCast(&entry.d_name);
const exqlite_priv = formatZ(&exqlite_priv_buf, "{s}/{s}/priv", .{ lib_path, d_name_c });
_ = jni.mkdir(exqlite_priv, 0o755);
var nif_link_buf: [760]u8 = undefined;
const nif_link = formatZ(&nif_link_buf, "{s}/sqlite3_nif.so", .{exqlite_priv});
var st_nif: jni.Stat = undefined;
if (jni.stat(nif_target, &st_nif) == 0) {
// nativeLibDir has the NIF (adb install) — use symlink
_ = jni.unlink(nif_link);
if (jni.symlink(nif_target, nif_link) == 0) {
logi("mob_start_beam: symlink exqlite NIF -> {s}", .{nif_target});
found = true;
} else {
loge("mob_start_beam: symlink exqlite NIF failed: {s}", .{lastErrno()});
}
} else {
// nativeLibDir empty — MobBridge extracted NIF directly to nif_link
var st_nif_file: jni.Stat = undefined;
if (jni.stat(nif_link, &st_nif_file) == 0) {
logi("mob_start_beam: exqlite NIF extracted from split APK", .{});
found = true;
} else {
loge("mob_start_beam: exqlite NIF missing from both nativeLibDir and priv/", .{});
}
}
break;
}
}
_ = jni.closedir(d);
}
if (!found) {
// Fallback: symlink into flat beams_dir/priv/ for backward compatibility
// while the deployer hasn't yet created the versioned lib structure.
var priv_dir_buf: [660]u8 = undefined;
const priv_dir = formatZ(&priv_dir_buf, "{s}/priv", .{beams_dir});
_ = jni.mkdir(priv_dir, 0o755);
var nif_link_buf: [720]u8 = undefined;
const nif_link = formatZ(&nif_link_buf, "{s}/sqlite3_nif.so", .{priv_dir});
var st_nif_fb: jni.Stat = undefined;
if (jni.stat(nif_target, &st_nif_fb) == 0) {
_ = jni.unlink(nif_link);
if (jni.symlink(nif_target, nif_link) == 0) {
logi("mob_start_beam: symlink sqlite3_nif.so (fallback) -> {s}", .{nif_target});
} else {
loge("mob_start_beam: symlink sqlite3_nif (fallback) failed: {s}", .{lastErrno()});
}
} else {
var st_fb_file: jni.Stat = undefined;
if (jni.stat(nif_link, &st_fb_file) == 0) {
logi("mob_start_beam: sqlite3_nif.so (fallback) extracted from split APK", .{});
} else {
loge("mob_start_beam: sqlite3_nif.so (fallback) missing — NIF load will fail", .{});
}
}
}
}
// Symlink libpythonx.so into the pythonx OTP lib structure for the
// same reason as exqlite above. Pythonx's NIF on_load does
// path = :filename.join(:code.priv_dir(:pythonx), 'libpythonx')
// :erlang.load_nif(path, 0)
// For dlopen to resolve enif_* (defined in the main app native lib)
// the .so has to live in the app's namespace — i.e. nativeLibraryDir.
// mob_dev's NativeBuild already places libpythonx.so in jniLibs, so
// the APK installer extracts it to nativeLibraryDir at install time.
// We just symlink into the OTP lib priv/ to make :code.priv_dir
// return a path that dlopen can follow.
if (s_native_lib_dir[0] != 0) {
var pyx_target_buf: [560]u8 = undefined;
const pyx_target = formatZ(&pyx_target_buf, "{s}/libpythonx.so", .{jni.asCStr(&s_native_lib_dir)});
var st_pyx: jni.Stat = undefined;
if (jni.stat(pyx_target, &st_pyx) == 0) {
var lib_path_buf: [600]u8 = undefined;
const lib_path = formatZ(&lib_path_buf, "{s}/lib", .{otp_root});
if (jni.opendir(lib_path)) |d2| {
while (jni.readdir(d2)) |entry| {
if (jni.strncmp(@ptrCast(&entry.d_name), "pythonx-", 8) == 0) {
var pyx_priv_buf: [700]u8 = undefined;
const d_name_c: [*:0]const u8 = @ptrCast(&entry.d_name);
const pyx_priv = formatZ(&pyx_priv_buf, "{s}/{s}/priv", .{ lib_path, d_name_c });
_ = jni.mkdir(pyx_priv, 0o755);
var pyx_link_buf: [760]u8 = undefined;
const pyx_link = formatZ(&pyx_link_buf, "{s}/libpythonx.so", .{pyx_priv});
_ = jni.unlink(pyx_link);
if (jni.symlink(pyx_target, pyx_link) == 0) {
logi("mob_start_beam: symlink pythonx NIF -> {s}", .{pyx_target});
} else {
loge("mob_start_beam: symlink pythonx NIF failed: {s}", .{lastErrno()});
}
break;
}
}
_ = jni.closedir(d2);
}
}
}
// erl_start blocks forever in the normal case. If it returns at all the
// BEAM has exited unexpectedly — report it to the UI and let logcat carry
// the details. The caller's caller (Java thread) will reap the process.
erl_start(@intCast(ac), @ptrCast(&args));
mob_set_startup_error("BEAM exited unexpectedly — see logcat (tag: MobBeam) for details");
loge("mob_start_beam: erl_start returned (unexpected)", .{});
}
// ── Comptime helpers ──────────────────────────────────────────────────────
fn selectDefaultFlags() []const [*:0]const u8 {
// String comparison at comptime — build_options.beam_flags_mode is a
// []const u8 baked into the binary at build time.
if (std.mem.eql(u8, BEAM_FLAGS_MODE, "untuned")) {
return &.{};
}
if (std.mem.eql(u8, BEAM_FLAGS_MODE, "sbwt_only")) {
return &.{
"-sbwt", "none",
"-sbwtdcpu", "none",
"-sbwtdio", "none",
};
}
// Default: full Nerves-style tuning.
return &.{
"-S", "1:1",
"-SDcpu", "1:1",
"-SDio", "1",
"-A", "1",
"-sbwt", "none",
"-sbwtdcpu", "none",
"-sbwtdio", "none",
};
}