From 2e2a9d9093a82f3db51a843fa51228127788c839 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Mon, 9 Nov 2026 11:53:51 +0500 Subject: [PATCH] fix(android): keep native tombstones and record runtime crash breadcrumbs --- NativeScript/runtime/android/jsi/Runtime.cpp | 41 +-- NativeScript/runtime/android/jsi/Runtime.h | 4 - .../jsi/modules/module/ModuleInternal.cpp | 2 + .../jsi/sighandler/CrashBreadcrumbs.cpp | 303 ++++++++++++++++++ .../android/jsi/sighandler/CrashBreadcrumbs.h | 59 ++++ .../android/jsi/sighandler/SIGHandler.h | 26 -- .../android/jsi/workers/WorkerWrapper.cpp | 2 + NativeScript/runtime/android/napi/Runtime.cpp | 41 +-- NativeScript/runtime/android/napi/Runtime.h | 4 - .../napi/modules/module/ModuleInternal.cpp | 2 + .../napi/sighandler/CrashBreadcrumbs.cpp | 303 ++++++++++++++++++ .../napi/sighandler/CrashBreadcrumbs.h | 59 ++++ .../android/napi/sighandler/SIGHandler.h | 26 -- .../android/napi/workers/WorkerWrapper.cpp | 2 + .../app/tests/exceptionHandlingTests.js | 15 - 15 files changed, 744 insertions(+), 145 deletions(-) create mode 100644 NativeScript/runtime/android/jsi/sighandler/CrashBreadcrumbs.cpp create mode 100644 NativeScript/runtime/android/jsi/sighandler/CrashBreadcrumbs.h delete mode 100644 NativeScript/runtime/android/jsi/sighandler/SIGHandler.h create mode 100644 NativeScript/runtime/android/napi/sighandler/CrashBreadcrumbs.cpp create mode 100644 NativeScript/runtime/android/napi/sighandler/CrashBreadcrumbs.h delete mode 100644 NativeScript/runtime/android/napi/sighandler/SIGHandler.h diff --git a/NativeScript/runtime/android/jsi/Runtime.cpp b/NativeScript/runtime/android/jsi/Runtime.cpp index 1e69bbe3d..72feeacb1 100644 --- a/NativeScript/runtime/android/jsi/Runtime.cpp +++ b/NativeScript/runtime/android/jsi/Runtime.cpp @@ -12,11 +12,10 @@ // through libzip's zipconf.h, which this file included but never used. #include #include "NativeScriptException.h" -#include #include "File.h" #include #include "Version.h" -#include "SIGHandler.h" +#include "CrashBreadcrumbs.h" #include "ArgConverter.h" #include "NativeScriptAssert.h" #include "CallbackHandlers.h" @@ -105,25 +104,7 @@ void Runtime::Init(JavaVM *vm) { NativeScriptException::Init(); } - // handle SIGABRT/SIGSEGV only on API level > 20 as the handling is not so efficient in older versions - if (m_androidVersion > 20) { - struct sigaction action = {}; - sigemptyset(&action.sa_mask); - action.sa_flags = 0; - action.sa_handler = SIGHandler; - sigaction(SIGABRT, &action, NULL); -#ifndef __JSC__ - // JavaScriptCore installs and relies on its OWN SIGSEGV handler for normal, - // non-fatal operation (concurrent GC / JIT). Overwriting it with a handler - // that unconditionally throws a C++ exception hijacks those legitimate - // faults and manufactures a crash — reliably reproduced under multithreaded - // JNI access (testConcurrentAccess). No active test relies on converting a - // SIGSEGV to a JS exception (the only such spec is disabled via xit), so on - // JSC we leave SIGSEGV to the engine. SIGABRT (which JSC does not use) is - // still converted. On every other engine both are converted as before. - sigaction(SIGSEGV, &action, NULL); -#endif - } + CrashBreadcrumbs::Install(); // Log uncaught native exceptions before aborting. std::set_terminate(LogAndAbortUncaught); @@ -279,6 +260,9 @@ void Runtime::Attach(::facebook::jsi::Runtime &hostRuntime, const std::string &f // JNI interop callbacks. void Runtime::InitCommon(const std::string &filesRoot, const std::string &callingDir, int maxLogcatObjectSize, bool forceLog, bool guest) { + CrashBreadcrumbs::OpenStore(filesRoot); + CrashBreadcrumbs::RegisterRuntime(m_id); + // The napi runtime opens a process-lifetime handle scope here (global_scope) // so that napi_values created outside any explicit scope have somewhere to // live. There is no such thing to hold open: an owned engine::Value roots @@ -503,19 +487,6 @@ void Runtime::InitCommon(const std::string &filesRoot, const std::string &callin DEBUG_WRITE("%s", "NativeScript Runtime Loaded!"); } -int Runtime::GetAndroidVersion() { - char sdkVersion[PROP_VALUE_MAX]; - __system_property_get("ro.build.version.sdk", sdkVersion); - - std::stringstream strValue; - strValue << sdkVersion; - - unsigned int intValue; - strValue >> intValue; - - return intValue; -} - ObjectManager *Runtime::GetObjectManager(engine::Runtime &rt) { return GetRuntime(rt)->GetObjectManager(); } @@ -567,6 +538,7 @@ std::string Runtime::ReadFileText(const std::string &filePath) { } void Runtime::DestroyRuntime() { + CrashBreadcrumbs::UnregisterRuntime(m_id); // Flush still-queued finalizer cleanup before is_destroying is set: with it // set, the cleanup leaks the engine handles it would release, and a quit // looper never drains it. Finalizers firing during teardown below run @@ -926,7 +898,6 @@ jmethodID Runtime::GET_USED_MEMORY_METHOD_ID = nullptr; tns::ConcurrentMap Runtime::id_to_runtime_cache; tns::ConcurrentMap Runtime::rt_to_runtime_cache; bool Runtime::s_mainThreadInitialized = false; -int Runtime::m_androidVersion = Runtime::GetAndroidVersion(); ALooper *Runtime::m_mainLooper = nullptr; tns::ConcurrentMap Runtime::thread_id_to_rt_cache; diff --git a/NativeScript/runtime/android/jsi/Runtime.h b/NativeScript/runtime/android/jsi/Runtime.h index 5088e1dc7..86c966913 100644 --- a/NativeScript/runtime/android/jsi/Runtime.h +++ b/NativeScript/runtime/android/jsi/Runtime.h @@ -221,10 +221,6 @@ namespace tns { std::shared_ptr m_looperTasks; - static int GetAndroidVersion(); - - static int m_androidVersion; - static JavaVM *java_vm; static jmethodID GET_USED_MEMORY_METHOD_ID; diff --git a/NativeScript/runtime/android/jsi/modules/module/ModuleInternal.cpp b/NativeScript/runtime/android/jsi/modules/module/ModuleInternal.cpp index e443bca30..f63448a14 100644 --- a/NativeScript/runtime/android/jsi/modules/module/ModuleInternal.cpp +++ b/NativeScript/runtime/android/jsi/modules/module/ModuleInternal.cpp @@ -7,6 +7,7 @@ #include "NativeScriptException.h" #include "Util.h" #include "CallbackHandlers.h" +#include "CrashBreadcrumbs.h" #include "Runtime.h" #include #include @@ -278,6 +279,7 @@ std::string ModuleInternal::EnsureFileProtocol(const std::string& path) { } engine::Value ModuleInternal::LoadModule(engine::Runtime& rt, const std::string& modulePath, const std::string& moduleCacheKey) { + CrashBreadcrumbs::ModuleScope moduleBreadcrumb(modulePath.c_str()); engine::Object context = rt.global(); engine::Object moduleObj(rt); diff --git a/NativeScript/runtime/android/jsi/sighandler/CrashBreadcrumbs.cpp b/NativeScript/runtime/android/jsi/sighandler/CrashBreadcrumbs.cpp new file mode 100644 index 000000000..1e0c4a3b5 --- /dev/null +++ b/NativeScript/runtime/android/jsi/sighandler/CrashBreadcrumbs.cpp @@ -0,0 +1,303 @@ +#include "CrashBreadcrumbs.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr size_t kMaxRuntimes = 16; +constexpr size_t kFieldMax = 160; +constexpr size_t kBufferMax = 8192; +constexpr size_t kHeaderMax = 128; + +struct Slot { + bool used; + bool isWorker; + int runtimeId; + int tid; + char script[kFieldMax]; + char module[kFieldMax]; +}; + +Slot g_slots[kMaxRuntimes]; +std::mutex g_mutex; + +/* + * Rendered in two buffers alternately, so the signal handler never reads the + * one a running thread is part way through writing. + */ +char g_rendered[2][kBufferMax]; +size_t g_renderedLength[2]; +std::atomic g_active{-1}; + +std::atomic g_storeFd{-1}; +std::atomic_flag g_recorded = ATOMIC_FLAG_INIT; +struct sigaction g_previous[NSIG]; + +thread_local Slot* t_slot = nullptr; + +int CurrentTid() { return static_cast(syscall(__NR_gettid)); } + +void CopyField(char* dst, const char* src) { + if (src == nullptr) { + dst[0] = '\0'; + return; + } + size_t length = strlen(src); + if (length < kFieldMax) { + memcpy(dst, src, length + 1); + return; + } + // Keep the tail: the file name identifies a module, the leading directories + // are shared by every module in the app. + memcpy(dst, "...", 3); + memcpy(dst + 3, src + length - (kFieldMax - 4), kFieldMax - 4); + dst[kFieldMax - 1] = '\0'; +} + +void Append(char* out, size_t& length, const char* format, ...) + __attribute__((format(printf, 3, 4))); + +void Append(char* out, size_t& length, const char* format, ...) { + if (length >= kBufferMax) { + return; + } + va_list args; + va_start(args, format); + int written = vsnprintf(out + length, kBufferMax - length, format, args); + va_end(args); + if (written > 0) { + length += static_cast(written); + if (length > kBufferMax - 1) { + length = kBufferMax - 1; + } + } +} + +void RenderLocked() { + // Once a crash is recorded the handler may be reading either buffer; a + // second flip after that point would rewrite the one it is copying out. + if (g_recorded.test(std::memory_order_acquire)) { + return; + } + int next = g_active.load(std::memory_order_relaxed) == 0 ? 1 : 0; + char* out = g_rendered[next]; + size_t length = 0; + + Append(out, length, "NativeScript runtime state (pid %d):\n", getpid()); + for (const Slot& slot : g_slots) { + if (!slot.used) { + continue; + } + Append(out, length, " runtime=%d tid=%d %s", slot.runtimeId, slot.tid, + slot.isWorker ? "worker" : "main"); + if (slot.script[0] != '\0') { + Append(out, length, " script=%s", slot.script); + } + Append(out, length, " module=%s\n", + slot.module[0] != '\0' ? slot.module : ""); + } + + g_renderedLength[next] = length; + g_active.store(next, std::memory_order_release); +} + +Slot* FindLocked(int runtimeId) { + for (Slot& slot : g_slots) { + if (slot.used && slot.runtimeId == runtimeId) { + return &slot; + } + } + return nullptr; +} + +/* Async-signal-safe integer formatting; snprintf is not usable here. */ +void AppendRaw(char* out, size_t capacity, size_t& length, const char* text) { + while (*text != '\0' && length < capacity) { + out[length++] = *text++; + } +} + +void AppendRawInt(char* out, size_t capacity, size_t& length, int value) { + char digits[16]; + size_t count = 0; + unsigned int magnitude = static_cast(value); + do { + digits[count++] = static_cast('0' + magnitude % 10); + magnitude /= 10; + } while (magnitude != 0 && count < sizeof(digits)); + while (count > 0 && length < capacity) { + out[length++] = digits[--count]; + } +} + +void Handler(int signalNumber, siginfo_t* info, void* context) { + // Only the first thread to fault records; the rest are already doomed. + if (!g_recorded.test_and_set()) { + int fd = g_storeFd.load(std::memory_order_acquire); + if (fd >= 0) { + char header[kHeaderMax]; + size_t length = 0; + AppendRaw(header, sizeof(header), length, "fatal signal "); + AppendRawInt(header, sizeof(header), length, signalNumber); + AppendRaw(header, sizeof(header), length, " on tid "); + AppendRawInt(header, sizeof(header), length, CurrentTid()); + AppendRaw(header, sizeof(header), length, "\n"); + + ssize_t written = pwrite(fd, header, length, 0); + int active = g_active.load(std::memory_order_acquire); + if (written > 0 && active >= 0) { + pwrite(fd, g_rendered[active], g_renderedLength[active], written); + } + } + } + + /* + * Hand the signal to whoever owned it before us -- on Android that is + * debuggerd, which writes the tombstone. + * + * A signal the kernel raised for a real fault arrives again on its own once + * this returns and the faulting instruction re-executes, so debuggerd is + * entered with the kernel's original siginfo instead of anything + * synthesised here. One that was delivered by abort() or kill() (si_code + * <= 0) will not come back, so it has to be re-raised explicitly. + */ + sigaction(signalNumber, &g_previous[signalNumber], nullptr); + if (info == nullptr || info->si_code <= 0) { + raise(signalNumber); + } +} + +} // namespace + +namespace tns { + +void CrashBreadcrumbs::Install() { + static std::once_flag once; + std::call_once(once, [] { + struct sigaction action = {}; + action.sa_sigaction = Handler; + // SA_ONSTACK matters for a stack-overflow SIGSEGV, which has no room left + // on the faulting stack to run a handler. bionic already gives every + // thread an alternate signal stack, so the flag is all that is needed. + action.sa_flags = SA_SIGINFO | SA_ONSTACK; + sigemptyset(&action.sa_mask); + for (int signalNumber : {SIGSEGV, SIGABRT, SIGBUS, SIGILL, SIGFPE}) { + sigaction(signalNumber, &action, &g_previous[signalNumber]); + } + }); +} + +void CrashBreadcrumbs::OpenStore(const std::string& filesRoot) { + static std::once_flag once; + std::call_once(once, [&filesRoot] { + std::string path = filesRoot + "/.ns-crash-breadcrumb"; + int fd = open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0600); + if (fd < 0) { + return; + } + + char previous[kBufferMax + kHeaderMax]; + ssize_t length = read(fd, previous, sizeof(previous) - 1); + if (length > 0) { + previous[length] = '\0'; + // Deliberately not ANDROID_LOG_FATAL: liblog feeds a fatal record to + // android_set_abort_message, and bionic keeps the first message it is + // given for the life of the process. Claiming that slot here would + // describe the *previous* process in this one's tombstone, and would + // shut out the abort message libc or ART writes for the real fault. + __android_log_print( + ANDROID_LOG_ERROR, "TNS.Native", + "The previous process was killed by a fatal signal. Runtime state " + "recorded at that moment (match tid against the tombstone in " + "/data/tombstones):\n%s", + previous); + ftruncate(fd, 0); + } + + g_storeFd.store(fd, std::memory_order_release); + }); +} + +void CrashBreadcrumbs::RegisterRuntime(int runtimeId) { + std::lock_guard lock(g_mutex); + Slot* slot = FindLocked(runtimeId); + if (slot == nullptr) { + for (Slot& candidate : g_slots) { + if (!candidate.used) { + slot = &candidate; + break; + } + } + } + if (slot == nullptr) { + // Table full. Keep the runtimes already tracked rather than evicting one. + return; + } + + slot->used = true; + slot->isWorker = false; + slot->runtimeId = runtimeId; + slot->tid = CurrentTid(); + slot->script[0] = '\0'; + slot->module[0] = '\0'; + t_slot = slot; + RenderLocked(); +} + +void CrashBreadcrumbs::UnregisterRuntime(int runtimeId) { + std::lock_guard lock(g_mutex); + Slot* slot = FindLocked(runtimeId); + if (slot == nullptr) { + return; + } + if (t_slot == slot) { + t_slot = nullptr; + } + slot->used = false; + RenderLocked(); +} + +void CrashBreadcrumbs::SetWorkerScript(int runtimeId, const char* script) { + std::lock_guard lock(g_mutex); + Slot* slot = FindLocked(runtimeId); + if (slot == nullptr) { + return; + } + slot->isWorker = true; + CopyField(slot->script, script); + RenderLocked(); +} + +CrashBreadcrumbs::ModuleScope::ModuleScope(const char* modulePath) { + Slot* slot = t_slot; + if (slot == nullptr) { + return; + } + std::lock_guard lock(g_mutex); + previous_ = slot->module; + restore_ = true; + CopyField(slot->module, modulePath); + RenderLocked(); +} + +CrashBreadcrumbs::ModuleScope::~ModuleScope() { + Slot* slot = t_slot; + if (!restore_ || slot == nullptr) { + return; + } + std::lock_guard lock(g_mutex); + CopyField(slot->module, previous_.c_str()); + RenderLocked(); +} + +} // namespace tns diff --git a/NativeScript/runtime/android/jsi/sighandler/CrashBreadcrumbs.h b/NativeScript/runtime/android/jsi/sighandler/CrashBreadcrumbs.h new file mode 100644 index 000000000..deacfa95a --- /dev/null +++ b/NativeScript/runtime/android/jsi/sighandler/CrashBreadcrumbs.h @@ -0,0 +1,59 @@ +#ifndef CRASHBREADCRUMBS_H_ +#define CRASHBREADCRUMBS_H_ + +#include + +namespace tns { + +/* + * Records what each runtime thread was doing, so a process killed by a fatal + * signal leaves behind more than a native backtrace. + * + * The state is rendered into a plain byte buffer as it changes, on ordinary + * threads. At crash time the only work left is a write(2) of that buffer, + * which is one of the few calls POSIX permits from a signal handler -- + * anything that allocates, takes a lock or formats has already happened. + */ +class CrashBreadcrumbs { + public: + /* + * Installs SIGSEGV/SIGABRT/SIGBUS/SIGILL/SIGFPE handlers that record the + * breadcrumb and then hand the signal back to the handler installed before + * them, so debuggerd still writes the tombstone. Idempotent. + */ + static void Install(); + + /* + * Points the store at the app's files directory and reports whatever a + * previous process left behind. Idempotent, so every runtime may call it. + */ + static void OpenStore(const std::string& filesRoot); + + /* Binds the calling thread to a runtime for that runtime's lifetime. */ + static void RegisterRuntime(int runtimeId); + static void UnregisterRuntime(int runtimeId); + + /* Marks a registered runtime as a worker started from `script`. */ + static void SetWorkerScript(int runtimeId, const char* script); + + /* + * Records the module the calling runtime is executing for the lifetime of + * the scope. Module loads nest (`require` inside a module body), so the + * enclosing module is restored on destruction, on throw paths included. + */ + class ModuleScope { + public: + explicit ModuleScope(const char* modulePath); + ~ModuleScope(); + ModuleScope(const ModuleScope&) = delete; + ModuleScope& operator=(const ModuleScope&) = delete; + + private: + std::string previous_; + bool restore_ = false; + }; +}; + +} // namespace tns + +#endif /* CRASHBREADCRUMBS_H_ */ diff --git a/NativeScript/runtime/android/jsi/sighandler/SIGHandler.h b/NativeScript/runtime/android/jsi/sighandler/SIGHandler.h deleted file mode 100644 index fa3d2a8cd..000000000 --- a/NativeScript/runtime/android/jsi/sighandler/SIGHandler.h +++ /dev/null @@ -1,26 +0,0 @@ -#ifndef SIGHANDLER_H -#define SIGHANDLER_H -#include -#include "NativeScriptException.h" -using namespace tns; - -void SIGHandler(int sigNumber) { - std::stringstream msg; - msg << "JNI Exception occurred ("; - switch (sigNumber) { - case SIGABRT: - msg << "SIGABRT"; - break; - case SIGSEGV: - msg << "SIGSEGV"; - break; - default: - // Shouldn't happen, but for completeness - msg << "Signal #" << sigNumber; - break; - } - msg << ").\n=======\nCheck the 'adb logcat' for additional information about the error.\n=======\n"; - throw NativeScriptException(msg.str()); -} - -#endif //SIGHANDLER_H \ No newline at end of file diff --git a/NativeScript/runtime/android/jsi/workers/WorkerWrapper.cpp b/NativeScript/runtime/android/jsi/workers/WorkerWrapper.cpp index 719633dcb..d398b0c14 100644 --- a/NativeScript/runtime/android/jsi/workers/WorkerWrapper.cpp +++ b/NativeScript/runtime/android/jsi/workers/WorkerWrapper.cpp @@ -9,6 +9,7 @@ #include "ArgConverter.h" #include "CallbackHandlers.h" +#include "CrashBreadcrumbs.h" #include "GlobalHelpers.h" #include "JEnv.h" #include "JniLocalRef.h" @@ -319,6 +320,7 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { runtimeId = env.CallStaticIntMethod(RUNTIME_CLASS, INIT_WORKER_RUNTIME_METHOD_ID, workerId_, (jstring) callingDir); runtime_ = Runtime::GetRuntime(runtimeId); + CrashBreadcrumbs::SetWorkerScript(runtimeId, workerPath_.c_str()); { std::lock_guard lock(looperMutex_); diff --git a/NativeScript/runtime/android/napi/Runtime.cpp b/NativeScript/runtime/android/napi/Runtime.cpp index 786e3327b..48edffc7b 100644 --- a/NativeScript/runtime/android/napi/Runtime.cpp +++ b/NativeScript/runtime/android/napi/Runtime.cpp @@ -10,11 +10,10 @@ #include #include "zipconf.h" #include "NativeScriptException.h" -#include #include "File.h" #include #include "Version.h" -#include "SIGHandler.h" +#include "CrashBreadcrumbs.h" #include "ArgConverter.h" #include "NativeScriptAssert.h" #include "CallbackHandlers.h" @@ -112,25 +111,7 @@ void Runtime::Init(JavaVM *vm) { NativeScriptException::Init(); } - // handle SIGABRT/SIGSEGV only on API level > 20 as the handling is not so efficient in older versions - if (m_androidVersion > 20) { - struct sigaction action = {}; - sigemptyset(&action.sa_mask); - action.sa_flags = 0; - action.sa_handler = SIGHandler; - sigaction(SIGABRT, &action, NULL); -#ifndef __JSC__ - // JavaScriptCore installs and relies on its OWN SIGSEGV handler for normal, - // non-fatal operation (concurrent GC / JIT). Overwriting it with a handler - // that unconditionally throws a C++ exception hijacks those legitimate - // faults and manufactures a crash — reliably reproduced under multithreaded - // JNI access (testConcurrentAccess). No active test relies on converting a - // SIGSEGV to a JS exception (the only such spec is disabled via xit), so on - // JSC we leave SIGSEGV to the engine. SIGABRT (which JSC does not use) is - // still converted. On every other engine both are converted as before. - sigaction(SIGSEGV, &action, NULL); -#endif - } + CrashBreadcrumbs::Install(); // Log uncaught native exceptions before aborting. std::set_terminate(LogAndAbortUncaught); @@ -222,6 +203,9 @@ void Runtime::Init(JNIEnv *_env, jstring filesPath, jstring nativeLibsDir, auto packageNameStr = ArgConverter::jstringToString(packageName); auto callingDirStr = ArgConverter::jstringToString(callingDir); + CrashBreadcrumbs::OpenStore(filesRoot); + CrashBreadcrumbs::RegisterRuntime(m_id); + Constants::APP_ROOT_FOLDER_PATH = filesRoot + "/app/"; DEBUG_WRITE("Initializing NativeScript NAPI Runtime"); @@ -446,19 +430,6 @@ void Runtime::Init(JNIEnv *_env, jstring filesPath, jstring nativeLibsDir, DEBUG_WRITE("%s", "NativeScript Runtime Loaded!"); } -int Runtime::GetAndroidVersion() { - char sdkVersion[PROP_VALUE_MAX]; - __system_property_get("ro.build.version.sdk", sdkVersion); - - std::stringstream strValue; - strValue << sdkVersion; - - unsigned int intValue; - strValue >> intValue; - - return intValue; -} - ObjectManager *Runtime::GetObjectManager(napi_env env) { return GetRuntime(env)->GetObjectManager(); } @@ -510,6 +481,7 @@ void Runtime::DestroyRuntime() { } is_destroying = true; JEnv::UnpinThreadEnv(); + CrashBreadcrumbs::UnregisterRuntime(m_id); delete m_loopTimer; m_loopTimer = nullptr; napi_status status; @@ -866,7 +838,6 @@ jmethodID Runtime::GET_USED_MEMORY_METHOD_ID = nullptr; tns::ConcurrentMap Runtime::id_to_runtime_cache; tns::ConcurrentMap Runtime::env_to_runtime_cache; bool Runtime::s_mainThreadInitialized = false; -int Runtime::m_androidVersion = Runtime::GetAndroidVersion(); ALooper *Runtime::m_mainLooper = nullptr; tns::ConcurrentMap Runtime::thread_id_to_rt_cache; diff --git a/NativeScript/runtime/android/napi/Runtime.h b/NativeScript/runtime/android/napi/Runtime.h index e52128e7e..8d4abf9a2 100644 --- a/NativeScript/runtime/android/napi/Runtime.h +++ b/NativeScript/runtime/android/napi/Runtime.h @@ -199,10 +199,6 @@ namespace tns { std::shared_ptr m_looperTasks; - static int GetAndroidVersion(); - - static int m_androidVersion; - static JavaVM *java_vm; static jmethodID GET_USED_MEMORY_METHOD_ID; diff --git a/NativeScript/runtime/android/napi/modules/module/ModuleInternal.cpp b/NativeScript/runtime/android/napi/modules/module/ModuleInternal.cpp index 44549a386..8e3ad6394 100644 --- a/NativeScript/runtime/android/napi/modules/module/ModuleInternal.cpp +++ b/NativeScript/runtime/android/napi/modules/module/ModuleInternal.cpp @@ -7,6 +7,7 @@ #include "NativeScriptException.h" #include "Util.h" #include "CallbackHandlers.h" +#include "CrashBreadcrumbs.h" #include "Runtime.h" #include #include @@ -386,6 +387,7 @@ std::string ModuleInternal::EnsureFileProtocol(const std::string& path) { } napi_value ModuleInternal::LoadModule(napi_env env, const std::string& modulePath, const std::string& moduleCacheKey) { + CrashBreadcrumbs::ModuleScope moduleBreadcrumb(modulePath.c_str()); napi_status status; napi_value result; diff --git a/NativeScript/runtime/android/napi/sighandler/CrashBreadcrumbs.cpp b/NativeScript/runtime/android/napi/sighandler/CrashBreadcrumbs.cpp new file mode 100644 index 000000000..1e0c4a3b5 --- /dev/null +++ b/NativeScript/runtime/android/napi/sighandler/CrashBreadcrumbs.cpp @@ -0,0 +1,303 @@ +#include "CrashBreadcrumbs.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr size_t kMaxRuntimes = 16; +constexpr size_t kFieldMax = 160; +constexpr size_t kBufferMax = 8192; +constexpr size_t kHeaderMax = 128; + +struct Slot { + bool used; + bool isWorker; + int runtimeId; + int tid; + char script[kFieldMax]; + char module[kFieldMax]; +}; + +Slot g_slots[kMaxRuntimes]; +std::mutex g_mutex; + +/* + * Rendered in two buffers alternately, so the signal handler never reads the + * one a running thread is part way through writing. + */ +char g_rendered[2][kBufferMax]; +size_t g_renderedLength[2]; +std::atomic g_active{-1}; + +std::atomic g_storeFd{-1}; +std::atomic_flag g_recorded = ATOMIC_FLAG_INIT; +struct sigaction g_previous[NSIG]; + +thread_local Slot* t_slot = nullptr; + +int CurrentTid() { return static_cast(syscall(__NR_gettid)); } + +void CopyField(char* dst, const char* src) { + if (src == nullptr) { + dst[0] = '\0'; + return; + } + size_t length = strlen(src); + if (length < kFieldMax) { + memcpy(dst, src, length + 1); + return; + } + // Keep the tail: the file name identifies a module, the leading directories + // are shared by every module in the app. + memcpy(dst, "...", 3); + memcpy(dst + 3, src + length - (kFieldMax - 4), kFieldMax - 4); + dst[kFieldMax - 1] = '\0'; +} + +void Append(char* out, size_t& length, const char* format, ...) + __attribute__((format(printf, 3, 4))); + +void Append(char* out, size_t& length, const char* format, ...) { + if (length >= kBufferMax) { + return; + } + va_list args; + va_start(args, format); + int written = vsnprintf(out + length, kBufferMax - length, format, args); + va_end(args); + if (written > 0) { + length += static_cast(written); + if (length > kBufferMax - 1) { + length = kBufferMax - 1; + } + } +} + +void RenderLocked() { + // Once a crash is recorded the handler may be reading either buffer; a + // second flip after that point would rewrite the one it is copying out. + if (g_recorded.test(std::memory_order_acquire)) { + return; + } + int next = g_active.load(std::memory_order_relaxed) == 0 ? 1 : 0; + char* out = g_rendered[next]; + size_t length = 0; + + Append(out, length, "NativeScript runtime state (pid %d):\n", getpid()); + for (const Slot& slot : g_slots) { + if (!slot.used) { + continue; + } + Append(out, length, " runtime=%d tid=%d %s", slot.runtimeId, slot.tid, + slot.isWorker ? "worker" : "main"); + if (slot.script[0] != '\0') { + Append(out, length, " script=%s", slot.script); + } + Append(out, length, " module=%s\n", + slot.module[0] != '\0' ? slot.module : ""); + } + + g_renderedLength[next] = length; + g_active.store(next, std::memory_order_release); +} + +Slot* FindLocked(int runtimeId) { + for (Slot& slot : g_slots) { + if (slot.used && slot.runtimeId == runtimeId) { + return &slot; + } + } + return nullptr; +} + +/* Async-signal-safe integer formatting; snprintf is not usable here. */ +void AppendRaw(char* out, size_t capacity, size_t& length, const char* text) { + while (*text != '\0' && length < capacity) { + out[length++] = *text++; + } +} + +void AppendRawInt(char* out, size_t capacity, size_t& length, int value) { + char digits[16]; + size_t count = 0; + unsigned int magnitude = static_cast(value); + do { + digits[count++] = static_cast('0' + magnitude % 10); + magnitude /= 10; + } while (magnitude != 0 && count < sizeof(digits)); + while (count > 0 && length < capacity) { + out[length++] = digits[--count]; + } +} + +void Handler(int signalNumber, siginfo_t* info, void* context) { + // Only the first thread to fault records; the rest are already doomed. + if (!g_recorded.test_and_set()) { + int fd = g_storeFd.load(std::memory_order_acquire); + if (fd >= 0) { + char header[kHeaderMax]; + size_t length = 0; + AppendRaw(header, sizeof(header), length, "fatal signal "); + AppendRawInt(header, sizeof(header), length, signalNumber); + AppendRaw(header, sizeof(header), length, " on tid "); + AppendRawInt(header, sizeof(header), length, CurrentTid()); + AppendRaw(header, sizeof(header), length, "\n"); + + ssize_t written = pwrite(fd, header, length, 0); + int active = g_active.load(std::memory_order_acquire); + if (written > 0 && active >= 0) { + pwrite(fd, g_rendered[active], g_renderedLength[active], written); + } + } + } + + /* + * Hand the signal to whoever owned it before us -- on Android that is + * debuggerd, which writes the tombstone. + * + * A signal the kernel raised for a real fault arrives again on its own once + * this returns and the faulting instruction re-executes, so debuggerd is + * entered with the kernel's original siginfo instead of anything + * synthesised here. One that was delivered by abort() or kill() (si_code + * <= 0) will not come back, so it has to be re-raised explicitly. + */ + sigaction(signalNumber, &g_previous[signalNumber], nullptr); + if (info == nullptr || info->si_code <= 0) { + raise(signalNumber); + } +} + +} // namespace + +namespace tns { + +void CrashBreadcrumbs::Install() { + static std::once_flag once; + std::call_once(once, [] { + struct sigaction action = {}; + action.sa_sigaction = Handler; + // SA_ONSTACK matters for a stack-overflow SIGSEGV, which has no room left + // on the faulting stack to run a handler. bionic already gives every + // thread an alternate signal stack, so the flag is all that is needed. + action.sa_flags = SA_SIGINFO | SA_ONSTACK; + sigemptyset(&action.sa_mask); + for (int signalNumber : {SIGSEGV, SIGABRT, SIGBUS, SIGILL, SIGFPE}) { + sigaction(signalNumber, &action, &g_previous[signalNumber]); + } + }); +} + +void CrashBreadcrumbs::OpenStore(const std::string& filesRoot) { + static std::once_flag once; + std::call_once(once, [&filesRoot] { + std::string path = filesRoot + "/.ns-crash-breadcrumb"; + int fd = open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0600); + if (fd < 0) { + return; + } + + char previous[kBufferMax + kHeaderMax]; + ssize_t length = read(fd, previous, sizeof(previous) - 1); + if (length > 0) { + previous[length] = '\0'; + // Deliberately not ANDROID_LOG_FATAL: liblog feeds a fatal record to + // android_set_abort_message, and bionic keeps the first message it is + // given for the life of the process. Claiming that slot here would + // describe the *previous* process in this one's tombstone, and would + // shut out the abort message libc or ART writes for the real fault. + __android_log_print( + ANDROID_LOG_ERROR, "TNS.Native", + "The previous process was killed by a fatal signal. Runtime state " + "recorded at that moment (match tid against the tombstone in " + "/data/tombstones):\n%s", + previous); + ftruncate(fd, 0); + } + + g_storeFd.store(fd, std::memory_order_release); + }); +} + +void CrashBreadcrumbs::RegisterRuntime(int runtimeId) { + std::lock_guard lock(g_mutex); + Slot* slot = FindLocked(runtimeId); + if (slot == nullptr) { + for (Slot& candidate : g_slots) { + if (!candidate.used) { + slot = &candidate; + break; + } + } + } + if (slot == nullptr) { + // Table full. Keep the runtimes already tracked rather than evicting one. + return; + } + + slot->used = true; + slot->isWorker = false; + slot->runtimeId = runtimeId; + slot->tid = CurrentTid(); + slot->script[0] = '\0'; + slot->module[0] = '\0'; + t_slot = slot; + RenderLocked(); +} + +void CrashBreadcrumbs::UnregisterRuntime(int runtimeId) { + std::lock_guard lock(g_mutex); + Slot* slot = FindLocked(runtimeId); + if (slot == nullptr) { + return; + } + if (t_slot == slot) { + t_slot = nullptr; + } + slot->used = false; + RenderLocked(); +} + +void CrashBreadcrumbs::SetWorkerScript(int runtimeId, const char* script) { + std::lock_guard lock(g_mutex); + Slot* slot = FindLocked(runtimeId); + if (slot == nullptr) { + return; + } + slot->isWorker = true; + CopyField(slot->script, script); + RenderLocked(); +} + +CrashBreadcrumbs::ModuleScope::ModuleScope(const char* modulePath) { + Slot* slot = t_slot; + if (slot == nullptr) { + return; + } + std::lock_guard lock(g_mutex); + previous_ = slot->module; + restore_ = true; + CopyField(slot->module, modulePath); + RenderLocked(); +} + +CrashBreadcrumbs::ModuleScope::~ModuleScope() { + Slot* slot = t_slot; + if (!restore_ || slot == nullptr) { + return; + } + std::lock_guard lock(g_mutex); + CopyField(slot->module, previous_.c_str()); + RenderLocked(); +} + +} // namespace tns diff --git a/NativeScript/runtime/android/napi/sighandler/CrashBreadcrumbs.h b/NativeScript/runtime/android/napi/sighandler/CrashBreadcrumbs.h new file mode 100644 index 000000000..deacfa95a --- /dev/null +++ b/NativeScript/runtime/android/napi/sighandler/CrashBreadcrumbs.h @@ -0,0 +1,59 @@ +#ifndef CRASHBREADCRUMBS_H_ +#define CRASHBREADCRUMBS_H_ + +#include + +namespace tns { + +/* + * Records what each runtime thread was doing, so a process killed by a fatal + * signal leaves behind more than a native backtrace. + * + * The state is rendered into a plain byte buffer as it changes, on ordinary + * threads. At crash time the only work left is a write(2) of that buffer, + * which is one of the few calls POSIX permits from a signal handler -- + * anything that allocates, takes a lock or formats has already happened. + */ +class CrashBreadcrumbs { + public: + /* + * Installs SIGSEGV/SIGABRT/SIGBUS/SIGILL/SIGFPE handlers that record the + * breadcrumb and then hand the signal back to the handler installed before + * them, so debuggerd still writes the tombstone. Idempotent. + */ + static void Install(); + + /* + * Points the store at the app's files directory and reports whatever a + * previous process left behind. Idempotent, so every runtime may call it. + */ + static void OpenStore(const std::string& filesRoot); + + /* Binds the calling thread to a runtime for that runtime's lifetime. */ + static void RegisterRuntime(int runtimeId); + static void UnregisterRuntime(int runtimeId); + + /* Marks a registered runtime as a worker started from `script`. */ + static void SetWorkerScript(int runtimeId, const char* script); + + /* + * Records the module the calling runtime is executing for the lifetime of + * the scope. Module loads nest (`require` inside a module body), so the + * enclosing module is restored on destruction, on throw paths included. + */ + class ModuleScope { + public: + explicit ModuleScope(const char* modulePath); + ~ModuleScope(); + ModuleScope(const ModuleScope&) = delete; + ModuleScope& operator=(const ModuleScope&) = delete; + + private: + std::string previous_; + bool restore_ = false; + }; +}; + +} // namespace tns + +#endif /* CRASHBREADCRUMBS_H_ */ diff --git a/NativeScript/runtime/android/napi/sighandler/SIGHandler.h b/NativeScript/runtime/android/napi/sighandler/SIGHandler.h deleted file mode 100644 index fa3d2a8cd..000000000 --- a/NativeScript/runtime/android/napi/sighandler/SIGHandler.h +++ /dev/null @@ -1,26 +0,0 @@ -#ifndef SIGHANDLER_H -#define SIGHANDLER_H -#include -#include "NativeScriptException.h" -using namespace tns; - -void SIGHandler(int sigNumber) { - std::stringstream msg; - msg << "JNI Exception occurred ("; - switch (sigNumber) { - case SIGABRT: - msg << "SIGABRT"; - break; - case SIGSEGV: - msg << "SIGSEGV"; - break; - default: - // Shouldn't happen, but for completeness - msg << "Signal #" << sigNumber; - break; - } - msg << ").\n=======\nCheck the 'adb logcat' for additional information about the error.\n=======\n"; - throw NativeScriptException(msg.str()); -} - -#endif //SIGHANDLER_H \ No newline at end of file diff --git a/NativeScript/runtime/android/napi/workers/WorkerWrapper.cpp b/NativeScript/runtime/android/napi/workers/WorkerWrapper.cpp index a7da02ad6..3b0252069 100644 --- a/NativeScript/runtime/android/napi/workers/WorkerWrapper.cpp +++ b/NativeScript/runtime/android/napi/workers/WorkerWrapper.cpp @@ -9,6 +9,7 @@ #include "ArgConverter.h" #include "CallbackHandlers.h" +#include "CrashBreadcrumbs.h" #include "GlobalHelpers.h" #include "JEnv.h" #include "JniLocalRef.h" @@ -366,6 +367,7 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { runtimeId = env.CallStaticIntMethod(RUNTIME_CLASS, INIT_WORKER_RUNTIME_METHOD_ID, workerId_, (jstring) callingDir); runtime_ = Runtime::GetRuntime(runtimeId); + CrashBreadcrumbs::SetWorkerScript(runtimeId, workerPath_.c_str()); { std::lock_guard lock(looperMutex_); diff --git a/platforms/android/test-app/app/src/main/assets/app/tests/exceptionHandlingTests.js b/platforms/android/test-app/app/src/main/assets/app/tests/exceptionHandlingTests.js index d26422c3f..51dfd52a5 100644 --- a/platforms/android/test-app/app/src/main/assets/app/tests/exceptionHandlingTests.js +++ b/platforms/android/test-app/app/src/main/assets/app/tests/exceptionHandlingTests.js @@ -323,19 +323,4 @@ describe("Tests exception handling ", function () { expect(errMsg).toContain("SyntaxError: Unexpected token 'class'"); expect(errMsg).toContain("File: (file:///data/data/com.tns.testapplication/files/app/tests/syntaxErrors.js:3:4)"); }); - - // run this test only for API level bigger than 25 as we have handling there - if(android.os.Build.VERSION.SDK_INT > 25 && android.os.Build.CPU_ABI != "x86" && android.os.Build.CPU_ABI != "x86_64") { - xit("Should handle SIGABRT and throw a NativeScript exception when incorrectly calling JNI methods", function () { - let myClassInstance = new com.tns.tests.MyTestBaseClass3(); - // public void callMeWithAString(java.lang.String[] stringArr, Runnable arbitraryInterface) - try { - myClassInstance.callMeWithAString("stringVal", new java.lang.Runnable({ run: () => {} })) - } catch (e) { - android.util.Log.d("~~~~~", "~~~~~~~~ " + e.toString()); - - expect(e.toString()).toContain("SIGABRT"); - } - }); - } }); \ No newline at end of file