| author | |
| committer | |
| log | d9fc5cc413b64a5ce5699007860952ea911fded2 |
| tree | f6d3fe0e0cfa52c34ad015f3bb00116f81354fc2 |
| parent | 1e031bc5ce597a74ec16e0cb1e609d173bec91f7 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- 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.55 files changed, 63 insertions(+), 19 deletions(-)
platform/linux/cargo.sh+3-1| ... | @@ -17,7 +17,9 @@ command -v zig >/dev/null || { echo "zig is required as the cross linker" >&2; e | ... | @@ -17,7 +17,9 @@ command -v zig >/dev/null || { echo "zig is required as the cross linker" >&2; e |
| 17 | triple=$arch-unknown-linux-gnu | 17 | triple=$arch-unknown-linux-gnu |
| 18 | rustup target add "$triple" >/dev/null | 18 | rustup target add "$triple" >/dev/null |
| 19 | variable=$(echo "$triple" | tr - _) | 19 | variable=$(echo "$triple" | tr - _) |
| 20 | # The build id ties an executable to its published .debug file. | ||
| 20 | env "CARGO_TARGET_$(echo "$triple" | tr 'a-z-' 'A-Z_')_LINKER=$here/cc.sh" \ | 21 | env "CARGO_TARGET_$(echo "$triple" | tr 'a-z-' 'A-Z_')_LINKER=$here/cc.sh" \ |
| 21 | "CC_$variable=$here/cc.sh" "AR_$variable=$here/ar.sh" \ | 22 | "CC_$variable=$here/cc.sh" "AR_$variable=$here/ar.sh" \ |
| 22 | ZIG_TARGET="$arch-linux-gnu.2.17" \ | 23 | ZIG_TARGET="$arch-linux-gnu.2.17" \ |
| 23 | cargo "$command" --target "$triple" "$@" | 24 | cargo "$command" --target "$triple" \ |
| 25 | --config "target.$triple.rustflags=['-C','link-arg=-Wl,--build-id']" "$@" |
tools/RELEASE.md+22-7| ... | @@ -29,6 +29,10 @@ latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64 | ... | @@ -29,6 +29,10 @@ latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64 |
| 29 | snowbound-2026-09-29-r10-linux-aarch64 | 29 | snowbound-2026-09-29-r10-linux-aarch64 |
| 30 | snowbound-2026-09-29-r10-windows-x86_64.exe | 30 | snowbound-2026-09-29-r10-windows-x86_64.exe |
| 31 | snowbound-2026-09-29-r10-windows-aarch64.exe | 31 | snowbound-2026-09-29-r10-windows-aarch64.exe |
| 32 | Snowbound-2026-09-29-r10-macos-aarch64.dSYM.zip debug info, optional: one per archive | ||
| 33 | snowbound-2026-09-29-r10-linux-x86_64.debug.zip | ||
| 34 | snowbound-2026-09-29-r10-windows-x86_64.debug.zip | ||
| 35 | ... | ||
| 32 | ``` | 36 | ``` |
| 33 | 37 | ||
| 34 | A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into | 38 | 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 | ... | @@ -154,13 +158,24 @@ and Instruments name functions; Cargo's release profile strips only debug info |
| 154 | `.cargo/config.toml` builds every target with frame pointers, and Rust's | 158 | `.cargo/config.toml` builds every target with frame pointers, and Rust's |
| 155 | standard library ships with them. The symbols cost about a fifth: Linux x86_64 | 159 | standard library ships with them. The symbols cost about a fifth: Linux x86_64 |
| 156 | grows from 53 to 62 MB, Windows x86_64 from 55 to 72 MB, and the macOS app | 160 | grows from 53 to 62 MB, Windows x86_64 from 55 to 72 MB, and the macOS app |
| 157 | already carried them. Line tables would take an executable to some 250 MB, and | 161 | already carried them. |
| 158 | a dSYM adds 34 MB zipped per Mac architecture, so neither ships; build with | 162 | |
| 159 | `CARGO_PROFILE_RELEASE_DEBUG=line-tables-only` for files and lines. Windows | 163 | The release builds with line tables, then moves them out of each executable |
| 160 | tools that read only PDBs see no names: the Rust targets here emit DWARF, from | 164 | into the build folder's zipped symbol files, which neither `latest.json` nor |
| 161 | which lld's PDB keeps only global symbols. glibc's `backtrace_symbols_fd` reads only | 165 | `build.json` names, so the app never downloads them: a dSYM for each Mac |
| 162 | dynamic symbols, so for a signal Linux's crash log starts the executable again | 166 | (matched by its UUID; `build_macos.py --dsym`), and for Linux and Windows a |
| 163 | with `--symbolize` to name its frames from the symbol table. | 167 | DWARF `.debug` file, which the executable names in its `.gnu_debuglink` (and on |
| 168 | Linux by its build id). Unzipped beside the executable, or the dSYM beside the | ||
| 169 | app, they give lldb, gdb, `perf`, Instruments and `llvm-symbolizer` files, | ||
| 170 | lines and inlined calls. They run about 35 MB zipped per Mac, 55 MB per Linux | ||
| 171 | and 40 MB per Windows architecture. Windows gets DWARF rather than a PDB: rustc | ||
| 172 | emits CodeView only for MSVC targets, and lld builds a PDB's functions and lines | ||
| 173 | from CodeView alone, so from these DWARF objects its PDB holds only the global | ||
| 174 | symbols, which few Rust functions are; WPA and Visual Studio see no names. | ||
| 175 | |||
| 176 | glibc's `backtrace_symbols_fd` reads only dynamic symbols, so for a signal | ||
| 177 | Linux's crash log starts the executable again with `--symbolize` to name its | ||
| 178 | frames from the symbol table. | ||
| 164 | 179 | ||
| 165 | ## In the app | 180 | ## In the app |
| 166 | 181 |
tools/canvas/build_macos.py+4| ... | @@ -15,6 +15,7 @@ import tempfile | ... | @@ -15,6 +15,7 @@ import tempfile |
| 15 | parser = argparse.ArgumentParser(description=__doc__) | 15 | parser = argparse.ArgumentParser(description=__doc__) |
| 16 | parser.add_argument('--release', action='store_true') | 16 | parser.add_argument('--release', action='store_true') |
| 17 | parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path') | 17 | parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path') |
| 18 | parser.add_argument('--dsym', type=Path, help="Move the executable's debug info into a dSYM at this path") | ||
| 18 | parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise") | 19 | parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise") |
| 19 | host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64' | 20 | host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64' |
| 20 | parser.add_argument('--arch', choices=['aarch64', 'x86_64'], default=host, help="The app's architecture; this Mac's by default") | 21 | 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) | ... | @@ -107,6 +108,9 @@ binary.parent.mkdir(parents=True, exist_ok=True) |
| 107 | pending = binary.with_suffix('.next') | 108 | pending = binary.with_suffix('.next') |
| 108 | shutil.copy2(built / ('release' if args.release else 'debug') / 'snowbound', pending) | 109 | shutil.copy2(built / ('release' if args.release else 'debug') / 'snowbound', pending) |
| 109 | pending.replace(binary) | 110 | pending.replace(binary) |
| 111 | if args.dsym: | ||
| 112 | subprocess.run(['dsymutil', binary, '-o', args.dsym], check=True) | ||
| 113 | subprocess.run(['strip', '-S', binary], check=True) | ||
| 110 | icons = root / 'crates/snowbound/assets/icon' | 114 | icons = root / 'crates/snowbound/assets/icon' |
| 111 | resources = bundle / 'Contents/Resources' | 115 | resources = bundle / 'Contents/Resources' |
| 112 | resources.mkdir(exist_ok=True) | 116 | resources.mkdir(exist_ok=True) |
tools/release.py+28-5| ... | @@ -11,6 +11,7 @@ import shutil | ... | @@ -11,6 +11,7 @@ import shutil |
| 11 | import subprocess | 11 | import subprocess |
| 12 | import sys | 12 | import sys |
| 13 | import tempfile | 13 | import tempfile |
| 14 | import zipfile | ||
| 14 | from zoneinfo import ZoneInfo | 15 | from zoneinfo import ZoneInfo |
| 15 | 16 | ||
| 16 | ROOT = Path(__file__).resolve().parents[1] | 17 | ROOT = Path(__file__).resolve().parents[1] |
| ... | @@ -141,6 +142,15 @@ def sign(files): | ... | @@ -141,6 +142,15 @@ def sign(files): |
| 141 | return output.split() | 142 | return output.split() |
| 142 | 143 | ||
| 143 | 144 | ||
| 145 | def split_debug(executable, debug): | ||
| 146 | """Moves `executable`'s debug info to `debug`, which its debug link then names.""" | ||
| 147 | sysroot = subprocess.check_output(['rustc', '--print', 'sysroot'], text=True).strip() | ||
| 148 | host = re.search(r'^host: (\S+)$', subprocess.check_output(['rustc', '-vV'], text=True), re.M)[1] | ||
| 149 | objcopy = Path(sysroot) / 'lib/rustlib' / host / 'bin/rust-objcopy' | ||
| 150 | run([objcopy, '--only-keep-debug', executable, debug]) | ||
| 151 | run([objcopy, '--strip-debug', f'--add-gnu-debuglink={debug}', executable]) | ||
| 152 | |||
| 153 | |||
| 144 | def zip_bundle(bundle, archive): | 154 | def zip_bundle(bundle, archive): |
| 145 | run(['ditto', '-c', '-k', '--norsrc', '--noextattr', '--noqtn', '--noacl', '--keepParent', bundle, archive]) | 155 | run(['ditto', '-c', '-k', '--norsrc', '--noextattr', '--noqtn', '--noacl', '--keepParent', bundle, archive]) |
| 146 | 156 | ||
| ... | @@ -155,8 +165,9 @@ def notary(): | ... | @@ -155,8 +165,9 @@ def notary(): |
| 155 | return arguments if signs_in else None | 165 | return arguments if signs_in else None |
| 156 | 166 | ||
| 157 | 167 | ||
| 158 | def build_mac(platform, folder, developer_id, notarize): | 168 | def build_mac(platform, folder, developer_id, notarize, symbols): |
| 159 | """The zipped app, which build_macos.py signs; 10.6's stays unsigned, as it predates Developer ID.""" | 169 | """The zipped app, which build_macos.py signs; 10.6's stays unsigned, as it predates Developer ID. |
| 170 | Its zipped dSYM goes to `symbols`.""" | ||
| 160 | bundle = folder / 'Snowbound.app' | 171 | bundle = folder / 'Snowbound.app' |
| 161 | if platform == 'macos-10.6': | 172 | if platform == 'macos-10.6': |
| 162 | signing = ['--snow-leopard'] | 173 | signing = ['--snow-leopard'] |
| ... | @@ -166,7 +177,9 @@ def build_mac(platform, folder, developer_id, notarize): | ... | @@ -166,7 +177,9 @@ def build_mac(platform, folder, developer_id, notarize): |
| 166 | signing = ['--sign', 'ad-hoc'] | 177 | signing = ['--sign', 'ad-hoc'] |
| 167 | if platform != 'macos-10.6': | 178 | if platform != 'macos-10.6': |
| 168 | signing += ['--arch', platform.removeprefix('macos-')] | 179 | signing += ['--arch', platform.removeprefix('macos-')] |
| 169 | run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, *signing]) | 180 | dsym = folder / 'Snowbound.dSYM' |
| 181 | run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, '--dsym', dsym, *signing]) | ||
| 182 | zip_bundle(dsym, symbols) | ||
| 170 | archive = folder / 'archive.zip' | 183 | archive = folder / 'archive.zip' |
| 171 | if notarize and platform != 'macos-10.6': | 184 | if notarize and platform != 'macos-10.6': |
| 172 | zip_bundle(bundle, archive) | 185 | zip_bundle(bundle, archive) |
| ... | @@ -234,12 +247,16 @@ def main(): | ... | @@ -234,12 +247,16 @@ def main(): |
| 234 | stage.mkdir(parents=True) | 247 | stage.mkdir(parents=True) |
| 235 | # The app reads its version from this as it compiles. | 248 | # The app reads its version from this as it compiles. |
| 236 | os.environ['SNOWBOUND_BUILD'] = name(version) | 249 | os.environ['SNOWBOUND_BUILD'] = name(version) |
| 250 | # For the symbol files; the executables shed it. | ||
| 251 | os.environ['CARGO_PROFILE_RELEASE_DEBUG'] = 'line-tables-only' | ||
| 237 | built = {} | 252 | built = {} |
| 253 | symbols = [] | ||
| 238 | for platform in args.platforms: | 254 | for platform in args.platforms: |
| 239 | if platform.startswith('macos'): | 255 | if platform.startswith('macos'): |
| 240 | work = stage / platform | 256 | work = stage / platform |
| 241 | work.mkdir() | 257 | work.mkdir() |
| 242 | built[platform] = build_mac(platform, work, developer_id, notarize) | 258 | symbols.append(stage / f'Snowbound-{name(version)}-{platform}.dSYM.zip') |
| 259 | built[platform] = build_mac(platform, work, developer_id, notarize, symbols[-1]) | ||
| 243 | linux = [platform.removeprefix('linux-') for platform in args.platforms if platform.startswith('linux')] | 260 | linux = [platform.removeprefix('linux-') for platform in args.platforms if platform.startswith('linux')] |
| 244 | if linux: | 261 | if linux: |
| 245 | built |= build_linux(linux) | 262 | built |= build_linux(linux) |
| ... | @@ -252,6 +269,12 @@ def main(): | ... | @@ -252,6 +269,12 @@ def main(): |
| 252 | prefix = 'Snowbound' if platform.startswith('macos') else 'snowbound' | 269 | prefix = 'Snowbound' if platform.startswith('macos') else 'snowbound' |
| 253 | files[platform] = stage / f'{prefix}-{name(version)}-{platform}{source.suffix}' | 270 | files[platform] = stage / f'{prefix}-{name(version)}-{platform}{source.suffix}' |
| 254 | shutil.copy2(source, files[platform]) | 271 | shutil.copy2(source, files[platform]) |
| 272 | if not platform.startswith('macos'): | ||
| 273 | debug = stage / f'{prefix}-{name(version)}-{platform}.debug' | ||
| 274 | split_debug(files[platform], debug) | ||
| 275 | symbols.append(debug.with_name(f'{debug.name}.zip')) | ||
| 276 | with zipfile.ZipFile(symbols[-1], 'w', zipfile.ZIP_DEFLATED) as archive: | ||
| 277 | archive.write(debug, debug.name) | ||
| 255 | signatures = sign(files.values()) | 278 | signatures = sign(files.values()) |
| 256 | build = { | 279 | build = { |
| 257 | 'version': name(version), | 280 | 'version': name(version), |
| ... | @@ -270,7 +293,7 @@ def main(): | ... | @@ -270,7 +293,7 @@ def main(): |
| 270 | partial = target.with_name(f'.{target.name}.partial') | 293 | partial = target.with_name(f'.{target.name}.partial') |
| 271 | shutil.rmtree(partial, ignore_errors=True) | 294 | shutil.rmtree(partial, ignore_errors=True) |
| 272 | partial.mkdir() | 295 | partial.mkdir() |
| 273 | for file in [*files.values(), stage / 'build.json', stage / 'build.json.sig']: | 296 | for file in [*files.values(), *symbols, stage / 'build.json', stage / 'build.json.sig']: |
| 274 | # copy() keeps the Linux executables executable for anyone running them off the share. | 297 | # copy() keeps the Linux executables executable for anyone running them off the share. |
| 275 | shutil.copy(file, partial / file.name) | 298 | shutil.copy(file, partial / file.name) |
| 276 | partial.rename(target) | 299 | partial.rename(target) |
tools/test_release.py+6-6| ... | @@ -66,19 +66,19 @@ class ReleaseTest(unittest.TestCase): | ... | @@ -66,19 +66,19 @@ class ReleaseTest(unittest.TestCase): |
| 66 | def signing(platform, developer_id, notarize): | 66 | def signing(platform, developer_id, notarize): |
| 67 | commands.clear() | 67 | commands.clear() |
| 68 | with tempfile.TemporaryDirectory() as stage: | 68 | with tempfile.TemporaryDirectory() as stage: |
| 69 | build_mac(platform, Path(stage), developer_id, notarize) | 69 | build_mac(platform, Path(stage), developer_id, notarize, Path(stage) / 'symbols.zip') |
| 70 | build = commands[0] | 70 | build = commands[0] |
| 71 | return (build[build.index(f'{stage}/Snowbound.app') + 1:], | 71 | return (build[build.index(f'{stage}/Snowbound.dSYM') + 1:], |
| 72 | [command[1] for command in commands[1:]]) | 72 | [command[1] for command in commands[1:]]) |
| 73 | 73 | ||
| 74 | self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']), | 74 | self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']), |
| 75 | (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'aarch64'], | 75 | (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'aarch64'], |
| 76 | ['-c', 'notarytool', 'stapler', '-c'])) | 76 | ['-c', '-c', 'notarytool', 'stapler', '-c'])) |
| 77 | self.assertEqual(signing('macos-x86_64', True, ['--key-id', 'K']), | 77 | self.assertEqual(signing('macos-x86_64', True, ['--key-id', 'K']), |
| 78 | (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'x86_64'], | 78 | (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'x86_64'], |
| 79 | ['-c', 'notarytool', 'stapler', '-c'])) | 79 | ['-c', '-c', 'notarytool', 'stapler', '-c'])) |
| 80 | self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc', '--arch', 'aarch64'], ['-c'])) | 80 | self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc', '--arch', 'aarch64'], ['-c', '-c'])) |
| 81 | self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c'])) | 81 | self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c', '-c'])) |
| 82 | finally: | 82 | finally: |
| 83 | scope['run'] = run | 83 | scope['run'] = run |
| 84 | 84 |