Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 81 additions & 11 deletions NativeScript/runtime/Helpers.mm
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
#include <dispatch/dispatch.h>
#include <dlfcn.h>
#include <execinfo.h>
#include <mach-o/loader.h>
#include <mach/machine.h>
#include <objc/runtime.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <uuid/uuid.h>
#include <atomic>
#include <cerrno>
#include <mutex>
Expand Down Expand Up @@ -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<const uint8_t*>(header) + sizeof(struct mach_header_64);
for (uint32_t i = 0; i < header->ncmds; i++) {
const struct load_command* command = reinterpret_cast<const struct load_command*>(cursor);
if (command->cmd == LC_UUID) {
uuid_string_t uuid;
uuid_unparse_upper(reinterpret_cast<const struct uuid_command*>(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<Dl_info> 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<const struct mach_header*>(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) {
Expand Down
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
123 changes: 123 additions & 0 deletions docs/native-stack-traces.md
Original file line number Diff line number Diff line change
@@ -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<char, ...>) + 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
`<slice>/dSYMs/NativeScript.framework.dSYM`. When the app gets the runtime
through Swift Package Manager, Xcode downloads it to
`~/Library/Developer/Xcode/DerivedData/<project>-<hash>/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).
1 change: 1 addition & 0 deletions v8ios.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down