diff --git a/platform/linux/cargo.sh b/platform/linux/cargo.sh index 70a588e6d45f126542954d2908fda05b0b4f160e..987cafac8c38b50f07b826af54f652bf21d90d0b 100755 --- a/platform/linux/cargo.sh +++ b/platform/linux/cargo.sh @@ -17,7 +17,9 @@ command -v zig >/dev/null || { echo "zig is required as the cross linker" >&2; e triple=$arch-unknown-linux-gnu rustup target add "$triple" >/dev/null variable=$(echo "$triple" | tr - _) +# The build id ties an executable to its published .debug file. env "CARGO_TARGET_$(echo "$triple" | tr 'a-z-' 'A-Z_')_LINKER=$here/cc.sh" \ "CC_$variable=$here/cc.sh" "AR_$variable=$here/ar.sh" \ ZIG_TARGET="$arch-linux-gnu.2.17" \ - cargo "$command" --target "$triple" "$@" + cargo "$command" --target "$triple" \ + --config "target.$triple.rustflags=['-C','link-arg=-Wl,--build-id']" "$@" diff --git a/tools/RELEASE.md b/tools/RELEASE.md index f1766b7c61c014fc37097d9d51585e026fea3404..6b7a6873b740a4b7f59a8cd8019324d435a0ff31 100644 --- a/tools/RELEASE.md +++ b/tools/RELEASE.md @@ -29,6 +29,10 @@ latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64 snowbound-2026-09-29-r10-linux-aarch64 snowbound-2026-09-29-r10-windows-x86_64.exe snowbound-2026-09-29-r10-windows-aarch64.exe + Snowbound-2026-09-29-r10-macos-aarch64.dSYM.zip debug info, optional: one per archive + snowbound-2026-09-29-r10-linux-x86_64.debug.zip + snowbound-2026-09-29-r10-windows-x86_64.debug.zip + ... ``` A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into @@ -154,13 +158,24 @@ and Instruments name functions; Cargo's release profile strips only debug info `.cargo/config.toml` builds every target with frame pointers, and Rust's standard library ships with them. The symbols cost about a fifth: Linux x86_64 grows from 53 to 62 MB, Windows x86_64 from 55 to 72 MB, and the macOS app -already carried them. Line tables would take an executable to some 250 MB, and -a dSYM adds 34 MB zipped per Mac architecture, so neither ships; build with -`CARGO_PROFILE_RELEASE_DEBUG=line-tables-only` for files and lines. Windows -tools that read only PDBs see no names: the Rust targets here emit DWARF, from -which lld's PDB keeps only global symbols. glibc's `backtrace_symbols_fd` reads only -dynamic symbols, so for a signal Linux's crash log starts the executable again -with `--symbolize` to name its frames from the symbol table. +already carried them. + +The release builds with line tables, then moves them out of each executable +into the build folder's zipped symbol files, which neither `latest.json` nor +`build.json` names, so the app never downloads them: a dSYM for each Mac +(matched by its UUID; `build_macos.py --dsym`), and for Linux and Windows a +DWARF `.debug` file, which the executable names in its `.gnu_debuglink` (and on +Linux by its build id). Unzipped beside the executable, or the dSYM beside the +app, they give lldb, gdb, `perf`, Instruments and `llvm-symbolizer` files, +lines and inlined calls. They run about 35 MB zipped per Mac, 55 MB per Linux +and 40 MB per Windows architecture. Windows gets DWARF rather than a PDB: rustc +emits CodeView only for MSVC targets, and lld builds a PDB's functions and lines +from CodeView alone, so from these DWARF objects its PDB holds only the global +symbols, which few Rust functions are; WPA and Visual Studio see no names. + +glibc's `backtrace_symbols_fd` reads only dynamic symbols, so for a signal +Linux's crash log starts the executable again with `--symbolize` to name its +frames from the symbol table. ## In the app diff --git a/tools/canvas/build_macos.py b/tools/canvas/build_macos.py index 3c9507fc180460635a1aad521e75b15a8af75bee..8918d7fb53a5e2745853900c54cee34b3080ec50 100644 --- a/tools/canvas/build_macos.py +++ b/tools/canvas/build_macos.py @@ -15,6 +15,7 @@ import tempfile parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--release', action='store_true') parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path') +parser.add_argument('--dsym', type=Path, help="Move the executable's debug info into a dSYM at this path") parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise") host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64' parser.add_argument('--arch', choices=['aarch64', 'x86_64'], default=host, help="The app's architecture; this Mac's by default") @@ -107,6 +108,9 @@ binary.parent.mkdir(parents=True, exist_ok=True) pending = binary.with_suffix('.next') shutil.copy2(built / ('release' if args.release else 'debug') / 'snowbound', pending) pending.replace(binary) +if args.dsym: + subprocess.run(['dsymutil', binary, '-o', args.dsym], check=True) + subprocess.run(['strip', '-S', binary], check=True) icons = root / 'crates/snowbound/assets/icon' resources = bundle / 'Contents/Resources' resources.mkdir(exist_ok=True) diff --git a/tools/release.py b/tools/release.py index 4d07a236a93c0e5b46ed9f0a15959039cb1c224b..728fb0e686dcabfeaa65879b38f288ccd7541888 100755 --- a/tools/release.py +++ b/tools/release.py @@ -11,6 +11,7 @@ import shutil import subprocess import sys import tempfile +import zipfile from zoneinfo import ZoneInfo ROOT = Path(__file__).resolve().parents[1] @@ -141,6 +142,15 @@ def sign(files): return output.split() +def split_debug(executable, debug): + """Moves `executable`'s debug info to `debug`, which its debug link then names.""" + sysroot = subprocess.check_output(['rustc', '--print', 'sysroot'], text=True).strip() + host = re.search(r'^host: (\S+)$', subprocess.check_output(['rustc', '-vV'], text=True), re.M)[1] + objcopy = Path(sysroot) / 'lib/rustlib' / host / 'bin/rust-objcopy' + run([objcopy, '--only-keep-debug', executable, debug]) + run([objcopy, '--strip-debug', f'--add-gnu-debuglink={debug}', executable]) + + def zip_bundle(bundle, archive): run(['ditto', '-c', '-k', '--norsrc', '--noextattr', '--noqtn', '--noacl', '--keepParent', bundle, archive]) @@ -155,8 +165,9 @@ def notary(): return arguments if signs_in else None -def build_mac(platform, folder, developer_id, notarize): - """The zipped app, which build_macos.py signs; 10.6's stays unsigned, as it predates Developer ID.""" +def build_mac(platform, folder, developer_id, notarize, symbols): + """The zipped app, which build_macos.py signs; 10.6's stays unsigned, as it predates Developer ID. + Its zipped dSYM goes to `symbols`.""" bundle = folder / 'Snowbound.app' if platform == 'macos-10.6': signing = ['--snow-leopard'] @@ -166,7 +177,9 @@ def build_mac(platform, folder, developer_id, notarize): signing = ['--sign', 'ad-hoc'] if platform != 'macos-10.6': signing += ['--arch', platform.removeprefix('macos-')] - run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, *signing]) + dsym = folder / 'Snowbound.dSYM' + run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, '--dsym', dsym, *signing]) + zip_bundle(dsym, symbols) archive = folder / 'archive.zip' if notarize and platform != 'macos-10.6': zip_bundle(bundle, archive) @@ -234,12 +247,16 @@ def main(): stage.mkdir(parents=True) # The app reads its version from this as it compiles. os.environ['SNOWBOUND_BUILD'] = name(version) + # For the symbol files; the executables shed it. + os.environ['CARGO_PROFILE_RELEASE_DEBUG'] = 'line-tables-only' built = {} + symbols = [] for platform in args.platforms: if platform.startswith('macos'): work = stage / platform work.mkdir() - built[platform] = build_mac(platform, work, developer_id, notarize) + symbols.append(stage / f'Snowbound-{name(version)}-{platform}.dSYM.zip') + built[platform] = build_mac(platform, work, developer_id, notarize, symbols[-1]) linux = [platform.removeprefix('linux-') for platform in args.platforms if platform.startswith('linux')] if linux: built |= build_linux(linux) @@ -252,6 +269,12 @@ def main(): prefix = 'Snowbound' if platform.startswith('macos') else 'snowbound' files[platform] = stage / f'{prefix}-{name(version)}-{platform}{source.suffix}' shutil.copy2(source, files[platform]) + if not platform.startswith('macos'): + debug = stage / f'{prefix}-{name(version)}-{platform}.debug' + split_debug(files[platform], debug) + symbols.append(debug.with_name(f'{debug.name}.zip')) + with zipfile.ZipFile(symbols[-1], 'w', zipfile.ZIP_DEFLATED) as archive: + archive.write(debug, debug.name) signatures = sign(files.values()) build = { 'version': name(version), @@ -270,7 +293,7 @@ def main(): partial = target.with_name(f'.{target.name}.partial') shutil.rmtree(partial, ignore_errors=True) partial.mkdir() - for file in [*files.values(), stage / 'build.json', stage / 'build.json.sig']: + for file in [*files.values(), *symbols, stage / 'build.json', stage / 'build.json.sig']: # copy() keeps the Linux executables executable for anyone running them off the share. shutil.copy(file, partial / file.name) partial.rename(target) diff --git a/tools/test_release.py b/tools/test_release.py index 8289b4db122ba9b33bdd90d643db3db0e96c1608..1d9b77db4f6d2c87e1ef36cb74cf2917976496fa 100644 --- a/tools/test_release.py +++ b/tools/test_release.py @@ -66,19 +66,19 @@ class ReleaseTest(unittest.TestCase): def signing(platform, developer_id, notarize): commands.clear() with tempfile.TemporaryDirectory() as stage: - build_mac(platform, Path(stage), developer_id, notarize) + build_mac(platform, Path(stage), developer_id, notarize, Path(stage) / 'symbols.zip') build = commands[0] - return (build[build.index(f'{stage}/Snowbound.app') + 1:], + return (build[build.index(f'{stage}/Snowbound.dSYM') + 1:], [command[1] for command in commands[1:]]) self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']), (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'aarch64'], - ['-c', 'notarytool', 'stapler', '-c'])) + ['-c', '-c', 'notarytool', 'stapler', '-c'])) self.assertEqual(signing('macos-x86_64', True, ['--key-id', 'K']), (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'x86_64'], - ['-c', 'notarytool', 'stapler', '-c'])) - self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc', '--arch', 'aarch64'], ['-c'])) - self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c'])) + ['-c', '-c', 'notarytool', 'stapler', '-c'])) + self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc', '--arch', 'aarch64'], ['-c', '-c'])) + self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c', '-c'])) finally: scope['run'] = run