diff --git a/NativeScript/runtime/Helpers.mm b/NativeScript/runtime/Helpers.mm index 41d5361b..eb2a1e34 100644 --- a/NativeScript/runtime/Helpers.mm +++ b/NativeScript/runtime/Helpers.mm @@ -4,11 +4,15 @@ #include #include #include +#include +#include #include #include #include +#include #include #include +#include #include #include #include @@ -771,37 +775,103 @@ void WriteDebugLine(tns::LogCategory category, const char* message) { return result; } +namespace { + +const char* ImageArch(const struct mach_header* header) { + switch (header->cputype) { + case CPU_TYPE_ARM64: + return (header->cpusubtype & ~CPU_SUBTYPE_MASK) == CPU_SUBTYPE_ARM64E ? "arm64e" : "arm64"; + case CPU_TYPE_X86_64: + return "x86_64"; + default: + return "?"; + } +} + +// Same spelling as `dwarfdump --uuid`, so a log line can be matched to its dSYM by eye. +std::string ImageUUID(const struct mach_header* header) { + if (header->magic != MH_MAGIC_64) { + return "?"; + } + const uint8_t* cursor = reinterpret_cast(header) + sizeof(struct mach_header_64); + for (uint32_t i = 0; i < header->ncmds; i++) { + const struct load_command* command = reinterpret_cast(cursor); + if (command->cmd == LC_UUID) { + uuid_string_t uuid; + uuid_unparse_upper(reinterpret_cast(command)->uuid, uuid); + return uuid; + } + cursor += command->cmdsize; + } + return "?"; +} + +const char* ImageName(const char* path) { + const char* slash = strrchr(path, '/'); + return slash != nullptr ? slash + 1 : path; +} + +} // namespace + +// Frames use the crash-report layout (`image address load_address + offset`) and end with +// the images' load addresses and UUIDs, so `atos` can resolve them against the dSYM. The +// symbol name is only dladdr's nearest exported symbol: the release framework has no local +// symbols, so for internal functions it names the wrong function. void tns::LogBacktrace(int skip) { void* callstack[128]; const int nMaxFrames = sizeof(callstack) / sizeof(callstack[0]); + const int addressWidth = int(2 + sizeof(void*) * 2); char buf[1024]; int nFrames = backtrace(callstack, nMaxFrames); char** symbols = backtrace_symbols(callstack, nFrames); + std::vector images; for (int i = skip; i < nFrames; i++) { Dl_info info; - if (dladdr(callstack[i], &info) && info.dli_sname) { - char* demangled = NULL; + if (!dladdr(callstack[i], &info) || info.dli_fbase == nullptr || info.dli_fname == nullptr) { + snprintf(buf, sizeof(buf), "%-3d %*p %s\n", i, addressWidth, callstack[i], symbols[i]); + Log(@"%s", buf); + continue; + } + + bool seen = false; + for (const Dl_info& image : images) { + seen = seen || image.dli_fbase == info.dli_fbase; + } + if (!seen) { + images.push_back(info); + } + + int written = snprintf(buf, sizeof(buf), "%-3d %-30s %*p %p + %zu", i, + ImageName(info.dli_fname), addressWidth, callstack[i], info.dli_fbase, + size_t((char*)callstack[i] - (char*)info.dli_fbase)); + if (info.dli_sname != nullptr && written > 0 && size_t(written) < sizeof(buf)) { + char* demangled = nullptr; int status = -1; if (info.dli_sname[0] == '_') { - demangled = abi::__cxa_demangle(info.dli_sname, NULL, 0, &status); + demangled = abi::__cxa_demangle(info.dli_sname, nullptr, nullptr, &status); } - snprintf(buf, sizeof(buf), "%-3d %*p %s + %zd\n", i, int(2 + sizeof(void*) * 2), callstack[i], - status == 0 ? demangled - : info.dli_sname == 0 ? symbols[i] - : info.dli_sname, + snprintf(buf + written, sizeof(buf) - written, " %s + %zd", + status == 0 ? demangled : info.dli_sname, (char*)callstack[i] - (char*)info.dli_saddr); free(demangled); - } else { - snprintf(buf, sizeof(buf), "%-3d %*p %s\n", i, int(2 + sizeof(void*) * 2), callstack[i], - symbols[i]); } - Log(@"%s", buf); + Log(@"%s\n", buf); } free(symbols); if (nFrames == nMaxFrames) { Log(@"[truncated]"); } + + if (!images.empty()) { + Log(@"Binary images:"); + for (const Dl_info& image : images) { + const struct mach_header* header = static_cast(image.dli_fbase); + snprintf(buf, sizeof(buf), "%p %s %s <%s> %s\n", image.dli_fbase, ImageName(image.dli_fname), + ImageArch(header), ImageUUID(header).c_str(), image.dli_fname); + Log(@"%s", buf); + } + } } const std::string tns::GetStackTrace(Isolate* isolate) { diff --git a/docs/README.md b/docs/README.md index ad006029..f01913bb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,8 @@ - [Messaging and `node:worker_threads`](worker-threads.md) — `MessagePort`, `MessageChannel`, `BroadcastChannel` and `MessageEvent`, the `node:worker_threads` real-vs-shim table and its documented deviations, the strong-until-closed port lifetime, HTML port enabling, and the transfer support matrix with its `DataCloneError` messages. +- [Native stack traces](native-stack-traces.md) — reading the native backtraces the runtime logs, why function names inside `NativeScript.framework` can be wrong in release builds, and resolving frames with the matching dSYM and `atos` (also what crash reporters need uploaded). + - [Node-API](node-api.md) — writing a Node-API addon for this runtime: registering a module and loading it with `require()`, getting the `napi_env` from native code, the threading contract, finalizer timing, which Node-API version applies, and the divergences from Node's `node_api.h`. ## Knowledge diff --git a/docs/error-handling.md b/docs/error-handling.md index 2e51ed96..04f83c46 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -200,6 +200,8 @@ globalThis.addEventListener("error", (e) => { }); ``` +`callStackSymbols` names frames inside `NativeScript.framework` by the nearest exported symbol, which is often the wrong function in release builds. For exact names, upload the runtime's dSYM to the crash reporter. `callStackReturnAddresses` on its own is not enough for server-side symbolication: each address also needs its image's load address, architecture and UUID. See [Native stack traces](native-stack-traces.md#crash-reporters). + Native side — for the `NSUncaughtExceptionHandler`/signal layer, read `exception.tns_javascriptStackTrace` (or the `userInfo` keys) to attach the JS stack to crashes that never pass through the JS event layer (escaped exceptions, `uncaughtErrorPolicy: "throw"`). ## Legacy hooks (deprecated) diff --git a/docs/native-stack-traces.md b/docs/native-stack-traces.md new file mode 100644 index 00000000..3e654cdc --- /dev/null +++ b/docs/native-stack-traces.md @@ -0,0 +1,123 @@ +# Native stack traces + +The runtime prints a native stack trace when an assertion fails +(`====== Assertion failed ======`), when it logs an uncaught JavaScript error +(`Native stack trace:`), and on a fatal Node-API error. This page explains how +to read those traces and how to turn them into exact function names, file names +and line numbers. + +## What a trace looks like + +``` +Native stack trace: +1 NativeScript 0x104ca2088 0x104a00000 + 2760840 v8::CppHeap::CollectGarbageInYoungGenerationForTesting(cppgc::EmbedderStackState) + 8048 +2 NativeScript 0x104b1ee7c 0x104a00000 + 1175164 tns::ModuleInternal::RunModule(v8::Isolate*, std::__1::basic_string) + 92 +3 MyApp 0x102f1a2b4 0x102f00000 + 107188 main + 120 +Binary images: +0x104a00000 NativeScript arm64 <03DD4E84-EE0C-3280-AF5D-5CC4087BB5A5> /private/var/containers/Bundle/Application/.../MyApp.app/Frameworks/NativeScript.framework/NativeScript +0x102f00000 MyApp arm64 <...> /private/var/containers/Bundle/Application/.../MyApp.app/MyApp +``` + +(The `NativeScript` frames are real offsets in the +`v9.1.0-next.2026-11-05-42101299477` build, so the commands below can be tried +against that release's dSYM.) + +Each frame is `index image address load-address + offset symbol + offset`, +the same layout as an Apple crash report. The `Binary images:` list at the end +has one line per image that appears in the trace: its load address, name, +architecture, UUID and path. + +## Why the function names can be wrong + +Release builds of `NativeScript.framework` ship without internal (non-exported) +symbols, which saves about 2.7 MB per app. The name printed after the offset is +the nearest *exported* symbol at or before the address. For the runtime's and +V8's internal functions, that is a different function. In the first frame +above, the real function is `v8::internal::CppHeap::CollectGarbage`, not the +`...ForTesting` function the log names. A large symbol offset (thousands of +bytes) is a strong hint that the name is wrong. + +The image offset (`0x104a00000 + 2760840`) is always exact. Use it with the +framework's dSYM to get the real name. + +Debug builds of the runtime (local development, the test suite) keep every +symbol, so their names are exact. + +## Getting the dSYM + +The dSYM must come from the same build as the framework in the app. Every build +has its own UUID, shown in the `Binary images:` line. + +- **From the release.** Each GitHub release of + [NativeScript/ios](https://github.com/NativeScript/ios/releases) has the dSYMs + attached, one per platform slice: + - `ios-arm64NativeScript.framework.dSYM.zip`: iPhone and iPad devices + - `ios-arm64_x86_64-simulatorNativeScript.framework.dSYM.zip`: iOS simulators + - `ios-arm64-maccatalystNativeScript.framework.dSYM.zip`: Mac Catalyst + + Each zip unpacks to a dSYM with the same prefix, for example + `ios-arm64NativeScript.framework.dSYM`. The commands below call it + `NativeScript.framework.dSYM`. +- **From the xcframework.** The xcframework carries the same dSYMs in + `/dSYMs/NativeScript.framework.dSYM`. When the app gets the runtime + through Swift Package Manager, Xcode downloads it to + `~/Library/Developer/Xcode/DerivedData/-/SourcePackages/artifacts/ios-spm/NativeScript/NativeScript.xcframework`. + +Check that the UUID matches the one in the log: + +```sh +dwarfdump --uuid NativeScript.framework.dSYM +# UUID: 03DD4E84-EE0C-3280-AF5D-5CC4087BB5A5 (arm64) .../DWARF/NativeScript +``` + +A dSYM with a different UUID gives wrong answers without any error, so do not +skip this step. + +## Resolving frames with `atos` + +Pass the dSYM, the architecture and the load address from the `Binary images:` +line, then the frame addresses for that image: + +```sh +atos -o NativeScript.framework.dSYM -arch arm64 -l 0x104a00000 \ + 0x104ca2088 0x104b1ee7c +# v8::internal::CppHeap::CollectGarbage(cppgc::internal::GCConfig) (in NativeScript) + 64 +# tns::ModuleInternal::RunModule(v8::Isolate*, std::__1::basic_string<...>) (in NativeScript) (ModuleInternal.mm:217) +``` + +If you only have the image offset, use a load address of `0x0` and pass the +offset in hex (`2760840` is `0x2a2088`): + +```sh +atos -o NativeScript.framework.dSYM -arch arm64 -l 0x0 0x2a2088 +``` + +Use the architecture printed in the `Binary images:` line. Simulator builds on +Intel Macs are `x86_64`. + +## Other images in the trace + +- **Your app** (`MyApp` above): use the dSYM Xcode produced when you built the + app (for an archive, Organizer → right-click the archive → Show in Finder → + `dSYMs`), with the same `atos` command. +- **System libraries** (`UIKitCore`, `libobjc.A.dylib`, `dyld`, ...): their + names come from their own exported symbols and are usually close enough to + read. Exact names need the device's system symbols, which Xcode downloads when + the device is first connected. + +## Crash reporters + +Crash reporters that symbolicate on their servers (Sentry, Crashlytics and +others) need the runtime's dSYM uploaded next to the app's own, otherwise +frames inside `NativeScript` stay unresolved or show the nearest exported name. +Upload the dSYM that matches the runtime version the app ships, from one of the +locations above. + +`NSException.callStackSymbols` names frames the same way as this log, so it has +the same limitation. `NSException.callStackReturnAddresses` gives the raw +addresses, but they cannot be resolved on their own: the framework loads at a +different address on every launch, so each address needs its image's load +address, architecture and UUID, the same data as this log's `Binary images:` +list. Reporter SDKs that capture native crashes collect that list themselves. +If you send raw addresses yourself, send the image list with them. See +[Error handling](error-handling.md#crash-reporter-integration). diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index 45ff68d4..652120a5 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -3137,6 +3137,7 @@ PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = ""; SKIP_INSTALL = YES; + STRIP_STYLE = "non-global"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;