Skip to content

Commit 7e1fdab

Browse files
authored
fix: restore native tombstones and record runtime crash breadcrumbs (#2007)
* fix: restore native tombstones and record runtime crash breadcrumbs SIG_handler was installed for SIGABRT and SIGSEGV and threw a NativeScriptException from inside the signal handler. Throwing a C++ exception from a signal handler is undefined behaviour: the unwinder cannot cross the kernel-built signal frame, so on arm64 the throw reached std::terminate -> LogAndAbortUncaught -> _Exit(EXIT_FAILURE) and the process died anyway. Installing sa_handler for SIGSEGV also displaced debuggerd, so no tombstone was written - no backtrace, no registers, no fault address. A crash produced four lines of logcat and nothing else. Replace it with a diagnostic-only handler that never throws. It records a pre-rendered breadcrumb with a single write(2) and hands the signal back to the handler that owned it before, so debuggerd still writes the tombstone with the kernel's original siginfo. The breadcrumb names every live runtime by id, tid, main-vs-worker, worker script and the module it last entered, recovering the identity the tombstone truncates to 15 characters of thread name. It is rendered on ordinary threads whenever it changes, so the handler allocates nothing, takes no lock, and calls no JNI, V8 or logging code. android_set_abort_message is deliberately not used: bionic keeps the first message it is given, so it cannot carry state that changes, and claiming the slot would shut out the abort message libc or ART writes for the real fault. std::set_terminate(LogAndAbortUncaught) is unchanged - it is the legitimate handler for genuine uncaught C++ exceptions and already _Exit()s. Fatal signals now surface as real native crashes instead of being converted into JS exceptions. The disabled exceptionHandlingTests spec that asserted a JNI misuse yields a catchable "SIGABRT" exception is removed along with the behaviour it documented. * fix: close breadcrumb races and restore enclosing module after nested loads Address review findings: - Stop rendering once a crash is recorded: a thread that kept running could otherwise flip the double buffer twice and rewrite the buffer the handler is copying out. - Make g_storeFd atomic with release/acquire ordering; handlers are installed at JNI_OnLoad, before OpenStore publishes the fd. - Replace SetCurrentModule with a scoped ModuleScope that restores the enclosing module on every return and throw path, so a crash after a nested require no longer blames the inner module.
1 parent 19faa3d commit 7e1fdab

7 files changed

Lines changed: 376 additions & 45 deletions

File tree

test-app/app/src/main/assets/app/tests/exceptionHandlingTests.js

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -319,19 +319,4 @@ describe("Tests exception handling ", function () {
319319
expect(errMsg).toContain("SyntaxError: Unexpected token 'class'");
320320
expect(errMsg).toContain("File: (file:///data/data/com.tns.testapplication/files/app/tests/syntaxErrors.js:3:4)");
321321
});
322-
323-
// run this test only for API level bigger than 25 as we have handling there
324-
if(android.os.Build.VERSION.SDK_INT > 25 && android.os.Build.CPU_ABI != "x86" && android.os.Build.CPU_ABI != "x86_64") {
325-
xit("Should handle SIGABRT and throw a NativeScript exception when incorrectly calling JNI methods", function () {
326-
let myClassInstance = new com.tns.tests.MyTestBaseClass3();
327-
// public void callMeWithAString(java.lang.String[] stringArr, Runnable arbitraryInterface)
328-
try {
329-
myClassInstance.callMeWithAString("stringVal", new java.lang.Runnable({ run: () => {} }))
330-
} catch (e) {
331-
android.util.Log.d("~~~~~", "~~~~~~~~ " + e.toString());
332-
333-
expect(e.toString()).toContain("SIGABRT");
334-
}
335-
});
336-
}
337322
});

test-app/runtime/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ add_library(
167167
src/main/cpp/CallbackHandlers.cpp
168168
src/main/cpp/ConcurrentQueue.cpp
169169
src/main/cpp/Constants.cpp
170+
src/main/cpp/CrashBreadcrumbs.cpp
170171
src/main/cpp/DirectBuffer.cpp
171172
src/main/cpp/ErrorEvents.cpp
172173
src/main/cpp/EventLoop.cpp
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
#include "CrashBreadcrumbs.h"
2+
3+
#include <android/log.h>
4+
#include <fcntl.h>
5+
#include <signal.h>
6+
#include <sys/syscall.h>
7+
#include <unistd.h>
8+
9+
#include <atomic>
10+
#include <cstdarg>
11+
#include <cstdio>
12+
#include <cstring>
13+
#include <mutex>
14+
15+
namespace {
16+
17+
constexpr size_t kMaxRuntimes = 16;
18+
constexpr size_t kFieldMax = 160;
19+
constexpr size_t kBufferMax = 8192;
20+
constexpr size_t kHeaderMax = 128;
21+
22+
struct Slot {
23+
bool used;
24+
bool isWorker;
25+
int runtimeId;
26+
int tid;
27+
char script[kFieldMax];
28+
char module[kFieldMax];
29+
};
30+
31+
Slot g_slots[kMaxRuntimes];
32+
std::mutex g_mutex;
33+
34+
/*
35+
* Rendered in two buffers alternately, so the signal handler never reads the
36+
* one a running thread is part way through writing.
37+
*/
38+
char g_rendered[2][kBufferMax];
39+
size_t g_renderedLength[2];
40+
std::atomic<int> g_active{-1};
41+
42+
std::atomic<int> g_storeFd{-1};
43+
std::atomic_flag g_recorded = ATOMIC_FLAG_INIT;
44+
struct sigaction g_previous[NSIG];
45+
46+
thread_local Slot* t_slot = nullptr;
47+
48+
int CurrentTid() { return static_cast<int>(syscall(__NR_gettid)); }
49+
50+
void CopyField(char* dst, const char* src) {
51+
if (src == nullptr) {
52+
dst[0] = '\0';
53+
return;
54+
}
55+
size_t length = strlen(src);
56+
if (length < kFieldMax) {
57+
memcpy(dst, src, length + 1);
58+
return;
59+
}
60+
// Keep the tail: the file name identifies a module, the leading directories
61+
// are shared by every module in the app.
62+
memcpy(dst, "...", 3);
63+
memcpy(dst + 3, src + length - (kFieldMax - 4), kFieldMax - 4);
64+
dst[kFieldMax - 1] = '\0';
65+
}
66+
67+
void Append(char* out, size_t& length, const char* format, ...)
68+
__attribute__((format(printf, 3, 4)));
69+
70+
void Append(char* out, size_t& length, const char* format, ...) {
71+
if (length >= kBufferMax) {
72+
return;
73+
}
74+
va_list args;
75+
va_start(args, format);
76+
int written = vsnprintf(out + length, kBufferMax - length, format, args);
77+
va_end(args);
78+
if (written > 0) {
79+
length += static_cast<size_t>(written);
80+
if (length > kBufferMax - 1) {
81+
length = kBufferMax - 1;
82+
}
83+
}
84+
}
85+
86+
void RenderLocked() {
87+
// Once a crash is recorded the handler may be reading either buffer; a
88+
// second flip after that point would rewrite the one it is copying out.
89+
if (g_recorded.test(std::memory_order_acquire)) {
90+
return;
91+
}
92+
int next = g_active.load(std::memory_order_relaxed) == 0 ? 1 : 0;
93+
char* out = g_rendered[next];
94+
size_t length = 0;
95+
96+
Append(out, length, "NativeScript runtime state (pid %d):\n", getpid());
97+
for (const Slot& slot : g_slots) {
98+
if (!slot.used) {
99+
continue;
100+
}
101+
Append(out, length, " runtime=%d tid=%d %s", slot.runtimeId, slot.tid,
102+
slot.isWorker ? "worker" : "main");
103+
if (slot.script[0] != '\0') {
104+
Append(out, length, " script=%s", slot.script);
105+
}
106+
Append(out, length, " module=%s\n",
107+
slot.module[0] != '\0' ? slot.module : "<none>");
108+
}
109+
110+
g_renderedLength[next] = length;
111+
g_active.store(next, std::memory_order_release);
112+
}
113+
114+
Slot* FindLocked(int runtimeId) {
115+
for (Slot& slot : g_slots) {
116+
if (slot.used && slot.runtimeId == runtimeId) {
117+
return &slot;
118+
}
119+
}
120+
return nullptr;
121+
}
122+
123+
/* Async-signal-safe integer formatting; snprintf is not usable here. */
124+
void AppendRaw(char* out, size_t capacity, size_t& length, const char* text) {
125+
while (*text != '\0' && length < capacity) {
126+
out[length++] = *text++;
127+
}
128+
}
129+
130+
void AppendRawInt(char* out, size_t capacity, size_t& length, int value) {
131+
char digits[16];
132+
size_t count = 0;
133+
unsigned int magnitude = static_cast<unsigned int>(value);
134+
do {
135+
digits[count++] = static_cast<char>('0' + magnitude % 10);
136+
magnitude /= 10;
137+
} while (magnitude != 0 && count < sizeof(digits));
138+
while (count > 0 && length < capacity) {
139+
out[length++] = digits[--count];
140+
}
141+
}
142+
143+
void Handler(int signalNumber, siginfo_t* info, void* context) {
144+
// Only the first thread to fault records; the rest are already doomed.
145+
if (!g_recorded.test_and_set()) {
146+
int fd = g_storeFd.load(std::memory_order_acquire);
147+
if (fd >= 0) {
148+
char header[kHeaderMax];
149+
size_t length = 0;
150+
AppendRaw(header, sizeof(header), length, "fatal signal ");
151+
AppendRawInt(header, sizeof(header), length, signalNumber);
152+
AppendRaw(header, sizeof(header), length, " on tid ");
153+
AppendRawInt(header, sizeof(header), length, CurrentTid());
154+
AppendRaw(header, sizeof(header), length, "\n");
155+
156+
ssize_t written = pwrite(fd, header, length, 0);
157+
int active = g_active.load(std::memory_order_acquire);
158+
if (written > 0 && active >= 0) {
159+
pwrite(fd, g_rendered[active], g_renderedLength[active], written);
160+
}
161+
}
162+
}
163+
164+
/*
165+
* Hand the signal to whoever owned it before us -- on Android that is
166+
* debuggerd, which writes the tombstone.
167+
*
168+
* A signal the kernel raised for a real fault arrives again on its own once
169+
* this returns and the faulting instruction re-executes, so debuggerd is
170+
* entered with the kernel's original siginfo instead of anything
171+
* synthesised here. One that was delivered by abort() or kill() (si_code
172+
* <= 0) will not come back, so it has to be re-raised explicitly.
173+
*/
174+
sigaction(signalNumber, &g_previous[signalNumber], nullptr);
175+
if (info == nullptr || info->si_code <= 0) {
176+
raise(signalNumber);
177+
}
178+
}
179+
180+
} // namespace
181+
182+
namespace tns {
183+
184+
void CrashBreadcrumbs::Install() {
185+
static std::once_flag once;
186+
std::call_once(once, [] {
187+
struct sigaction action = {};
188+
action.sa_sigaction = Handler;
189+
// SA_ONSTACK matters for a stack-overflow SIGSEGV, which has no room left
190+
// on the faulting stack to run a handler. bionic already gives every
191+
// thread an alternate signal stack, so the flag is all that is needed.
192+
action.sa_flags = SA_SIGINFO | SA_ONSTACK;
193+
sigemptyset(&action.sa_mask);
194+
for (int signalNumber : {SIGSEGV, SIGABRT, SIGBUS, SIGILL, SIGFPE}) {
195+
sigaction(signalNumber, &action, &g_previous[signalNumber]);
196+
}
197+
});
198+
}
199+
200+
void CrashBreadcrumbs::OpenStore(const std::string& filesRoot) {
201+
static std::once_flag once;
202+
std::call_once(once, [&filesRoot] {
203+
std::string path = filesRoot + "/.ns-crash-breadcrumb";
204+
int fd = open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0600);
205+
if (fd < 0) {
206+
return;
207+
}
208+
209+
char previous[kBufferMax + kHeaderMax];
210+
ssize_t length = read(fd, previous, sizeof(previous) - 1);
211+
if (length > 0) {
212+
previous[length] = '\0';
213+
// Deliberately not ANDROID_LOG_FATAL: liblog feeds a fatal record to
214+
// android_set_abort_message, and bionic keeps the first message it is
215+
// given for the life of the process. Claiming that slot here would
216+
// describe the *previous* process in this one's tombstone, and would
217+
// shut out the abort message libc or ART writes for the real fault.
218+
__android_log_print(
219+
ANDROID_LOG_ERROR, "TNS.Native",
220+
"The previous process was killed by a fatal signal. Runtime state "
221+
"recorded at that moment (match tid against the tombstone in "
222+
"/data/tombstones):\n%s",
223+
previous);
224+
ftruncate(fd, 0);
225+
}
226+
227+
g_storeFd.store(fd, std::memory_order_release);
228+
});
229+
}
230+
231+
void CrashBreadcrumbs::RegisterRuntime(int runtimeId) {
232+
std::lock_guard<std::mutex> lock(g_mutex);
233+
Slot* slot = FindLocked(runtimeId);
234+
if (slot == nullptr) {
235+
for (Slot& candidate : g_slots) {
236+
if (!candidate.used) {
237+
slot = &candidate;
238+
break;
239+
}
240+
}
241+
}
242+
if (slot == nullptr) {
243+
// Table full. Keep the runtimes already tracked rather than evicting one.
244+
return;
245+
}
246+
247+
slot->used = true;
248+
slot->isWorker = false;
249+
slot->runtimeId = runtimeId;
250+
slot->tid = CurrentTid();
251+
slot->script[0] = '\0';
252+
slot->module[0] = '\0';
253+
t_slot = slot;
254+
RenderLocked();
255+
}
256+
257+
void CrashBreadcrumbs::UnregisterRuntime(int runtimeId) {
258+
std::lock_guard<std::mutex> lock(g_mutex);
259+
Slot* slot = FindLocked(runtimeId);
260+
if (slot == nullptr) {
261+
return;
262+
}
263+
if (t_slot == slot) {
264+
t_slot = nullptr;
265+
}
266+
slot->used = false;
267+
RenderLocked();
268+
}
269+
270+
void CrashBreadcrumbs::SetWorkerScript(int runtimeId, const char* script) {
271+
std::lock_guard<std::mutex> lock(g_mutex);
272+
Slot* slot = FindLocked(runtimeId);
273+
if (slot == nullptr) {
274+
return;
275+
}
276+
slot->isWorker = true;
277+
CopyField(slot->script, script);
278+
RenderLocked();
279+
}
280+
281+
CrashBreadcrumbs::ModuleScope::ModuleScope(const char* modulePath) {
282+
Slot* slot = t_slot;
283+
if (slot == nullptr) {
284+
return;
285+
}
286+
std::lock_guard<std::mutex> lock(g_mutex);
287+
previous_ = slot->module;
288+
restore_ = true;
289+
CopyField(slot->module, modulePath);
290+
RenderLocked();
291+
}
292+
293+
CrashBreadcrumbs::ModuleScope::~ModuleScope() {
294+
Slot* slot = t_slot;
295+
if (!restore_ || slot == nullptr) {
296+
return;
297+
}
298+
std::lock_guard<std::mutex> lock(g_mutex);
299+
CopyField(slot->module, previous_.c_str());
300+
RenderLocked();
301+
}
302+
303+
} // namespace tns
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#ifndef CRASHBREADCRUMBS_H_
2+
#define CRASHBREADCRUMBS_H_
3+
4+
#include <string>
5+
6+
namespace tns {
7+
8+
/*
9+
* Records what each runtime thread was doing, so a process killed by a fatal
10+
* signal leaves behind more than a native backtrace.
11+
*
12+
* The state is rendered into a plain byte buffer as it changes, on ordinary
13+
* threads. At crash time the only work left is a write(2) of that buffer,
14+
* which is one of the few calls POSIX permits from a signal handler --
15+
* anything that allocates, takes a lock or formats has already happened.
16+
*/
17+
class CrashBreadcrumbs {
18+
public:
19+
/*
20+
* Installs SIGSEGV/SIGABRT/SIGBUS/SIGILL/SIGFPE handlers that record the
21+
* breadcrumb and then hand the signal back to the handler installed before
22+
* them, so debuggerd still writes the tombstone. Idempotent.
23+
*/
24+
static void Install();
25+
26+
/*
27+
* Points the store at the app's files directory and reports whatever a
28+
* previous process left behind. Idempotent, so every runtime may call it.
29+
*/
30+
static void OpenStore(const std::string& filesRoot);
31+
32+
/* Binds the calling thread to a runtime for that runtime's lifetime. */
33+
static void RegisterRuntime(int runtimeId);
34+
static void UnregisterRuntime(int runtimeId);
35+
36+
/* Marks a registered runtime as a worker started from `script`. */
37+
static void SetWorkerScript(int runtimeId, const char* script);
38+
39+
/*
40+
* Records the module the calling runtime is executing for the lifetime of
41+
* the scope. Module loads nest (`require` inside a module body), so the
42+
* enclosing module is restored on destruction, on throw paths included.
43+
*/
44+
class ModuleScope {
45+
public:
46+
explicit ModuleScope(const char* modulePath);
47+
~ModuleScope();
48+
ModuleScope(const ModuleScope&) = delete;
49+
ModuleScope& operator=(const ModuleScope&) = delete;
50+
51+
private:
52+
std::string previous_;
53+
bool restore_ = false;
54+
};
55+
};
56+
57+
} // namespace tns
58+
59+
#endif /* CRASHBREADCRUMBS_H_ */

0 commit comments

Comments
 (0)