From d9fc5cc413b64a5ce5699007860952ea911fded2 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 1 Oct 2026 17:43:38 -0700 Subject: [PATCH] feat: each release publishes its debug info beside it - Each build's folder holds optional symbol files with full line tables: a zipped dSYM per Mac, and a zipped DWARF .debug file per Linux and Windows executable, which the executable finds by its debug link Release builds compile with line tables and move them out of each executable, so the shipped binaries stay their size and the updater never downloads the symbol files. Linux executables gain a build id. Windows gets DWARF rather than a PDB, since lld builds a PDB's functions and lines from CodeView alone and rustc emits CodeView only for MSVC targets. Assisted-by: claude-opus-5.5 --- platform/linux/cargo.sh | 4 +++- tools/RELEASE.md | 29 ++++++++++++++++++++++------- tools/canvas/build_macos.py | 4 ++++ tools/release.py | 33 ++++++++++++++++++++++++++++----- tools/test_release.py | 12 ++++++------ 5 files changed, 63 insertions(+), 19 deletions(-) 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 -- 2.54.0