Attaching LLDB to production Electron.js builds on macOS
TL;DR: This article describes how to use LLDB to debug production builds of Electron.js on macOS
Debugging native code from a development Electron.js build is easy and well documented. However, there are cases in which you might need to attach a debugger on a production build of Electron.js and see more than assembly instructions. This article describes how you can have a reasonably sane debugging session on macOS production Electron.js builds.
I’m running Electron.js v16.0.9 on Apple M1 with macOS 12.2.1, but the same instructions should apply to Intel macOS as long as you use the corresponding x64 binaries.
LLDB Without Debugging Symbols
If we attach lldb(1)
on a production Electron.js
build, we won’t see more than assembly instructions. To see how
this looks like, let’s first download and extract Electron.js
v16.0.9:
curl --location --remote-name https://github.com/electron/electron/releases/download/v16.0.9/electron-v16.0.9-darwin-arm64.zip
unzip electron-v16.0.9-darwin-arm64.zip
Because this is a production build, there are not many
symbols we can attach a breakpoint on. For the sake of the
experiment, we can pick a symbol that Electron.js exposes on
production builds, like ElectronMain
. This global
symbol is declared with default visibility in shell/app/electron_library_main.h
:
__attribute__((visibility("default"))) int ElectronMain(int argc, char* argv[]);
The ElectronMain
function is defined as part of
Electron Framework.framework
. We can inspect that
such symbol is indeed available by taking a look at
Electron Framework
using nm(1)
:
$ nm -gU Electron.app/Contents/Frameworks/Electron\ Framework.framework/Versions/A/Electron\ Framework | grep ElectronMain
0000000000018320 T _ElectronMain
Let’s start lldb(1)
, break on
ElectronMain
, run the target and see what we
get:
(lldb) target create "Electron.app/Contents/MacOS/Electron"
Current executable set to '/Users/jviotti/Downloads/Electron.app/Contents/MacOS/Electron' (arm64).
(lldb) breakpoint set --name ElectronMain
Breakpoint 1: where = Electron Framework`ElectronMain, address = 0x0000000000018320
(lldb) run
Process 43720 launched: '/Users/jviotti/Downloads/Electron.app/Contents/MacOS/Electron' (arm64)
Process 43720 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x00000001082cc320 Electron Framework`ElectronMain
Electron Framework`ElectronMain:
-> 0x1082cc320 <+0>: sub sp, sp, #0xb0 ; =0xb0
0x1082cc324 <+4>: stp x22, x21, [sp, #0x80]
0x1082cc328 <+8>: stp x20, x19, [sp, #0x90]
0x1082cc32c <+12>: stp x29, x30, [sp, #0xa0]
Target 0: (Electron) stopped.
As expected, assembly instructions is all we get.
DWARF Debugging Symbols
On Apple platforms, debugging symbols come in two flavours:
Mach-O binaries may include debugging symbols in their symbol
tables (referred to as the Stabs debugging format) or split into
separate .dSYM
files based on the DWARF debugging
data format.
Debugging symbols take significant space. For example, the
uncompressed Electron.js DWARF debugging symbols for v16.0.9
used in this article are over 4 GB. Therefore, shipping
production Electron.js builds with Stabs would not be ideal for
end-users. Instead, the Electron.js project publishes separate
DWARF-based debugging symbols as part of every official release.
These packages are suffixed with -dsym
and must not
be confused with the -symbols
packages Electron.js
also provides. The latter correspond to Breakpad symbols as
covered in a
previous post.
Let’s download and extract the debugging symbols associated with our version of Electron.js:
curl --location --remote-name https://github.com/electron/electron/releases/download/v16.0.9/electron-v16.0.9-darwin-arm64-dsym.zip
unzip electron-v16.0.9-darwin-arm64.zip -d symbols
The ZIP includes .dSYM
folders that correspond
to the various Mach-O binaries that are included in the
Electron.js application bundle:
Electron Framework.dSYM/
Electron Helper (GPU).dSYM/
Electron Helper (Plugin).dSYM/
Electron Helper (Renderer).dSYM/
Electron Helper.dSYM/
Electron.dSYM/
Mantle.dSYM/
ReactiveObjC.dSYM/
ShipIt.dSYM/
Squirrel.dSYM/
chrome_crashpad_handler.dSYM/
libEGL.dylib.dSYM/
libGLESv2.dylib.dSYM/
libVkICD_mock_icd.dylib.dSYM/
libffmpeg.dylib.dSYM/
libswiftshader_libEGL.dylib.dSYM/
libswiftshader_libGLESv2.dylib.dSYM/
libvk_swiftshader.dylib.dSYM/
To prevent developers from accidentally loading debugging
symbols that do not match their corresponding targets, both the
DWARF file and its corresponding binary are tagged with a
LC_UUID
Mach-O load command that consists of the
same UUID. It is important to confirm that you have the right
debugging symbols. If you don’t, lldb(1)
will not
be able to associate them with your targets.
One way to cross-check the UUIDs is to directly inspect the
LC_UUID
load commands using otool(1)
.
For example, we can validate
Electron Framework.framework
against
Electron Framework.dSYM
as follows:
$ otool -l Electron.app/Contents/Frameworks/Electron\ Framework.framework/Versions/A/Electron\ Framework
...
cmd LC_UUID
cmdsize 24
uuid 4C4C4449-5555-3144-A12E-B47FF06382B1
...
$ otool -l symbols/Electron\ Framework.dSYM/Contents/Resources/DWARF/Electron\ Framework
...
cmd LC_UUID
cmdsize 24
uuid 4C4C4449-5555-3144-A12E-B47FF06382B1
...
Alternatively, we can print the UUIDs directly using
dwarfdump(1)
:
$ dwarfdump -uuid Electron.app/Contents/Frameworks/Electron\ Framework.framework/Versions/A/Electron\ Framework
UUID: 4C4C4449-5555-3144-A12E-B47FF06382B1 (arm64) Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework
$ dwarfdump -uuid symbols/Electron\ Framework.dSYM
UUID: 4C4C4449-5555-3144-A12E-B47FF06382B1 (arm64) symbols/Electron Framework.dSYM/Contents/Resources/DWARF/Electron Framework
Loading DWARF Symbols on LLDB
Debugging symbols can be loaded by using the
target symbols add
command. For example, let’s
start lldb(1)
on Electron.js and load the symbols
corresponding to Electron
and
Electron Framework
:
(lldb) target create "Electron.app/Contents/MacOS/Electron"
Current executable set to '/Users/jviotti/Downloads/Electron.app/Contents/MacOS/Electron' (arm64).
(lldb) target symbols add symbols/Electron.dSYM/
symbol file '/Users/jviotti/Downloads/symbols/Electron.dSYM/Contents/Resources/DWARF/Electron' has been added to '/Users/jviotti/Downloads/Electron.app/Contents/MacOS/Electron'
(lldb) target symbols add symbols/Electron\ Framework.dSYM/
symbol file '/Users/jviotti/Downloads/symbols/Electron Framework.dSYM/Contents/Resources/DWARF/Electron Framework' has been added to '/Users/jviotti/Downloads/Electron.app/Contents/Frameworks/Electron Framework.framework/Electron Framework'
We can see that lldb(1)
reports that it
correctly associated our DWARF symbols with the corresponding
binaries. We can further confirm that the .dSYM
packages were associated correctly and print the matching UUIDs
by using image list
:
(lldb) image list
[ 0] 4C4C4473-5555-3144-A11C-BA3B9222E1BE 0x0000000100000000 /Users/jviotti/Downloads/Electron.app/Contents/MacOS/Electron
/Users/jviotti/Downloads/symbols/Electron.dSYM/Contents/Resources/DWARF/Electron
...
[ 14] 4C4C4449-5555-3144-A12E-B47FF06382B1 0x0000000000000000 /Users/jviotti/Downloads/Electron.app/Contents/Frameworks/Electron Framework.framework/Electron Framework
/Users/jviotti/Downloads/symbols/Electron Framework.dSYM/Contents/Resources/DWARF/Electron Framework
...
Instead of manually loading each .dSYM
folder,
we should be able to enable external symbol lookup and add the
symbols directory to the search path as follows:
(lldb) settings set symbols.enable-external-lookup true
(lldb) settings set target.debug-file-search-paths /Users/jviotti/Downloads/symbols
However, for some reason I don’t understand,
lldb(1)
doesn’t realize which symbols it should
load. Your mileage may vary!
Finding Available Symbols
Production Electron.js builds are compiled with optimizations
enabled. Therefore, not every symbol declared in the source code
will be available to break on. Compilers may inline, remove or
re-write certain functions. lldb(1)
even warns
developers about this fact when setting a breakpoint on
Electron Framework
:
Electron Framework was compiled with optimization - stepping may behave oddly; variables may not be available.
One strategy to find out about the symbols that are still
available consists in taking a look at the corresponding DWARF
files using dwarfdump(1)
. While the output may be
overwhelming, skimming through it will quickly highlight several
available symbols. For example, here are some of the available
symbols I was able to quickly find on
Electron Framework
:
$ dwarfdump --debug-info symbols/Electron\ Framework.dSYM
...
0x0003bc64: DW_TAG_subprogram
DW_AT_low_pc (0x0000000000018320)
DW_AT_high_pc (0x00000000000183c8)
DW_AT_frame_base (DW_OP_reg29 W29)
DW_AT_call_all_calls (true)
DW_AT_name ("ElectronMain")
DW_AT_decl_file ("/mnt/sfroot/_App/GomaWork/src/4527/Users/distiller/project/src/out/Default/../../electron/shell/app/electron_library_main.mm")
DW_AT_decl_line (18)
DW_AT_type (0x0000000000038375 "int")
DW_AT_external (true)
DW_AT_APPLE_optimized (true)
...
0x0004378f: DW_TAG_subprogram
DW_AT_low_pc (0x0000000000018570)
DW_AT_high_pc (0x00000000000185b8)
DW_AT_frame_base (DW_OP_reg29 W29)
DW_AT_call_all_calls (true)
DW_AT_linkage_name ("_ZN8electron16IsSandboxEnabledEPN4base11CommandLineE")
DW_AT_name ("IsSandboxEnabled")
DW_AT_decl_file ("/mnt/sfroot/_App/GomaWork/src/4695/Users/distiller/project/src/out/Default/../../electron/shell/app/command_line_args.cc")
DW_AT_decl_line (58)
DW_AT_type (0x0000000000041b8f "bool")
DW_AT_external (true)
DW_AT_APPLE_optimized (true)
...
0x000444f5: DW_TAG_subprogram
DW_AT_linkage_name ("_ZN8electron12_GLOBAL__N_121ComputeBuiltInPluginsEPNSt3__16vectorIN7content16PepperPluginInfoENS1_9allocatorIS4_EEEE")
DW_AT_name ("ComputeBuiltInPlugins")
DW_AT_decl_file ("/mnt/sfroot/_App/GomaWork/src/4302/Users/distiller/project/src/out/Default/../../electron/shell/app/electron_content_client.cc")
DW_AT_decl_line (105)
DW_AT_APPLE_optimized (true)
DW_AT_inline (DW_INL_inlined)
...
Normally, you will start a debugging session already knowing
what you want to break on. In those cases, you can check if the
symbol you are interested in is available using
image lookup
:
(lldb) image lookup --name IsSandboxEnabled
1 match found in /Users/jviotti/Downloads/Electron.app/Contents/Frameworks/Electron Framework.framework/Electron Framework:
Address: Electron Framework[0x0000000000018570] (Electron Framework.__TEXT.__text + 90864)
Summary: Electron Framework`electron::IsSandboxEnabled(base::CommandLine*) at command_line_args.cc:58
In the above example, we located the
IsSandboxEnabled
symbol, saw its declaration, and
the file and line number in which it was defined
(command_line_args.cc:58
). Our debugging symbols
are working!
Configuring Source Maps
Debugging symbols associate addresses with human-friendly
locations in the source code, but do not embed the actual the
source code. Before we can set breakpoints, we need to tell
lldb(1)
where to find the corresponding source code
that was used the produce the given build.
First of all, let’s download and extract the Electron.js v16.0.9 source code archive from GitHub. Cloning the repository and checking out the corresponding tag is equally valid:
curl --location --remote-name https://github.com/electron/electron/archive/refs/tags/v16.0.9.zip
unzip v16.0.9.zip
DWARF debugging symbols associate a symbol to the absolute path of its corresponding source code file as present in the computer that performed the compilation. To speed up compilation, Electron.js makes use of the Goma distributed compiler service. As a consequence of Goma’s distributed nature, the absolute path to the source code file where the compilation originally took place tends to vary across compilation units.
We can inspect the absolute path associated with a given
symbol using image lookup
and its
--verbose
flag. For example, the absolute path to
the source code associated with some of the symbols we
previously looked at have different Goma-related base
directories:
(lldb) image lookup --verbose --name ElectronMain
...
CompileUnit: id = {0x00000023}, file = "/mnt/sfroot/_App/GomaWork/src/4527/Users/distiller/project/src/electron/shell/app/electron_library_main.mm", language = "objective-c++"
...
(lldb) image lookup --verbose --name IsSandboxEnabled
...
CompileUnit: id = {0x00000024}, file = "/mnt/sfroot/_App/GomaWork/src/4695/Users/distiller/project/src/electron/shell/app/command_line_args.cc", language = "c++14"
...
(lldb) image lookup --verbose --name ComputeBuiltInPlugins
...
CompileUnit: id = {0x00000025}, file = "/mnt/sfroot/_App/GomaWork/src/4302/Users/distiller/project/src/electron/shell/app/electron_content_client.cc", language = "c++14"
...
The target.source-map
setting consist of a
sequence of pairs that allows us to re-map the base directories
of the original source files to a base directory of our own.
For example, we can map
/mnt/sfroot/_App/GomaWork/src/4527/Users/distiller/project/src/electron
to our local Electron.js source code as follows:
(lldb) settings set target.source-map /mnt/sfroot/_App/GomaWork/src/4527/Users/distiller/project/src/electron /Users/jviotti/Downloads/electron-16.0.9
Settings Breakpoints
At this point, we are in a position to set breakpoints on our
production Electron.js build and resolve symbols correctly.
Given what we have learnt, let’s set a breakpoint on
ElectronMain
:
(lldb) target create "Electron.app/Contents/MacOS/Electron"
Current executable set to '/Users/jviotti/Downloads/Electron.app/Contents/MacOS/Electron' (arm64).
(lldb) target symbols add /Users/jviotti/Downloads/symbols/Electron\ Framework.dSYM
symbol file '/Users/jviotti/Downloads/symbols/Electron Framework.dSYM/Contents/Resources/DWARF/Electron Framework' has been added to '/Users/jviotti/Downloads/Electron.app/Contents/Frameworks/Electron Framework.framework/Electron Framework'
(lldb) image lookup --verbose --name ElectronMain
...
CompileUnit: id = {0x00000023}, file = "/mnt/sfroot/_App/GomaWork/src/4527/Users/distiller/project/src/electron/shell/app/electron_library_main.mm", language = "objective-c++"
...
(lldb) settings set target.source-map /mnt/sfroot/_App/GomaWork/src/4527/Users/distiller/project/src/electron /Users/jviotti/Downloads/electron-16.0.9
(lldb) breakpoint set --name ElectronMain
Breakpoint 1: where = Electron Framework`ElectronMain + 32 at electron_library_main.mm:19:34, address = 0x0000000000018340
Optimization-related warnings aside, hitting run
will present us with source code that we can inspect as
usual:
(lldb) run
Process 69574 launched: '/Users/jviotti/Downloads/Electron.app/Contents/MacOS/Electron' (arm64)
Electron Framework was compiled with optimization - stepping may behave oddly; variables may not be available.
Process 69574 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x00000001082cc340 Electron Framework`ElectronMain(argc=1, argv=0x000000016fdff0f0) at electron_library_main.mm:19:34 [opt]
16 #include "shell/common/mac/main_application_bundle.h"
17
18 int ElectronMain(int argc, char* argv[]) {
-> 19 electron::ElectronMainDelegate delegate;
20 content::ContentMainParams params(&delegate);
21 params.argc = argc;
22 params.argv = const_cast<const char**>(argv);
Target 0: (Electron) stopped.
However, stepping into a function defined as part of another
compilation unit, like
electron::ElectronCommandLine::Init
which is called
by ElectronMain
a couple of statements afterwards,
will take us back to square one again:
Process 69574 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step over
frame #0: 0x00000001082cc37c Electron Framework`ElectronMain(argc=1, argv=0x000000016fdff0f0) at electron_library_main.mm:23:3 [opt]
20 content::ContentMainParams params(&delegate);
21 params.argc = argc;
22 params.argv = const_cast<const char**>(argv);
-> 23 electron::ElectronCommandLine::Init(argc, argv);
24 return content::ContentMain(params);
25 }
26
Target 0: (Electron) stopped.
(lldb) step
Process 69574 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step in
frame #0: 0x0000000108409b94 Electron Framework`electron::ElectronCommandLine::Init(argc=1, argv=0x000000016fdff0f0) at electron_command_line.cc:23:10 [opt]
Target 0: (Electron) stopped.
We know that because of Goma,
electron_command_line.cc
is probably recorded under
a different base directory. To keep going, we can inspect
electron::ElectronCommandLine::Init
with
image lookup
and re-adjust
target.source-map
:
(lldb) image lookup --verbose --name electron::ElectronCommandLine::Init
...
CompileUnit: id = {0x000000bd}, file = "/mnt/sfroot/_App/GomaWork/src/4601/Users/distiller/project/src/electron/shell/common/electron_command_line.cc", language = "c++14"
...
(lldb) settings set target.source-map /mnt/sfroot/_App/GomaWork/src/4601/Users/distiller/project/src/electron /Users/jviotti/Downloads/electron-16.0.9
(lldb) frame select
frame #0: 0x0000000108409b94 Electron Framework`electron::ElectronCommandLine::Init(argc=1, argv=0x000000016fdff0f0) at electron_command_line.cc:23:10 [opt]
20 // Otherwise we'd have to convert the arguments from UTF16.
21 #if !defined(OS_WIN)
22 // Hack around with the argv pointer. Used for process.title = "blah"
-> 23 argv = uv_setup_args(argc, argv);
24 #endif
25
26 argv_.assign(argv, argv + argc);