| 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 | 17 | triple=$arch-unknown-linux-gnu |
| 18 | 18 | rustup target add "$triple" >/dev/null |
| 19 | 19 | variable=$(echo "$triple" | tr - _) |
| 20 | # The build id ties an executable to its published .debug file. | |
| 20 | 21 | env "CARGO_TARGET_$(echo "$triple" | tr 'a-z-' 'A-Z_')_LINKER=$here/cc.sh" \ |
| 21 | 22 | "CC_$variable=$here/cc.sh" "AR_$variable=$here/ar.sh" \ |
| 22 | 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 | 29 | snowbound-2026-09-29-r10-linux-aarch64 |
| 30 | 30 | snowbound-2026-09-29-r10-windows-x86_64.exe |
| 31 | 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 | 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 | 158 | `.cargo/config.toml` builds every target with frame pointers, and Rust's |
| 155 | 159 | standard library ships with them. The symbols cost about a fifth: Linux x86_64 |
| 156 | 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 | |
| 158 | a dSYM adds 34 MB zipped per Mac architecture, so neither ships; build with | |
| 159 | `CARGO_PROFILE_RELEASE_DEBUG=line-tables-only` for files and lines. Windows | |
| 160 | tools that read only PDBs see no names: the Rust targets here emit DWARF, from | |
| 161 | which lld's PDB keeps only global symbols. glibc's `backtrace_symbols_fd` reads only | |
| 162 | dynamic symbols, so for a signal Linux's crash log starts the executable again | |
| 163 | with `--symbolize` to name its frames from the symbol table. | |
| 161 | already carried them. | |
| 162 | ||
| 163 | The release builds with line tables, then moves them out of each executable | |
| 164 | into the build folder's zipped symbol files, which neither `latest.json` nor | |
| 165 | `build.json` names, so the app never downloads them: a dSYM for each Mac | |
| 166 | (matched by its UUID; `build_macos.py --dsym`), and for Linux and Windows a | |
| 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 | 180 | ## In the app |
| 166 | 181 |
tools/canvas/build_macos.py+4| ... | ... | @@ -15,6 +15,7 @@ import tempfile |
| 15 | 15 | parser = argparse.ArgumentParser(description=__doc__) |
| 16 | 16 | parser.add_argument('--release', action='store_true') |
| 17 | 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 | 19 | parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise") |
| 19 | 20 | host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64' |
| 20 | 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 | 108 | pending = binary.with_suffix('.next') |
| 108 | 109 | shutil.copy2(built / ('release' if args.release else 'debug') / 'snowbound', pending) |
| 109 | 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 | 114 | icons = root / 'crates/snowbound/assets/icon' |
| 111 | 115 | resources = bundle / 'Contents/Resources' |
| 112 | 116 | resources.mkdir(exist_ok=True) |
tools/release.py+28-5| ... | ... | @@ -11,6 +11,7 @@ import shutil |
| 11 | 11 | import subprocess |
| 12 | 12 | import sys |
| 13 | 13 | import tempfile |
| 14 | import zipfile | |
| 14 | 15 | from zoneinfo import ZoneInfo |
| 15 | 16 | |
| 16 | 17 | ROOT = Path(__file__).resolve().parents[1] |
| ... | ... | @@ -141,6 +142,15 @@ def sign(files): |
| 141 | 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 | 154 | def zip_bundle(bundle, archive): |
| 145 | 155 | run(['ditto', '-c', '-k', '--norsrc', '--noextattr', '--noqtn', '--noacl', '--keepParent', bundle, archive]) |
| 146 | 156 | |
| ... | ... | @@ -155,8 +165,9 @@ def notary(): |
| 155 | 165 | return arguments if signs_in else None |
| 156 | 166 | |
| 157 | 167 | |
| 158 | def build_mac(platform, folder, developer_id, notarize): | |
| 159 | """The zipped app, which build_macos.py signs; 10.6's stays unsigned, as it predates Developer ID.""" | |
| 168 | def build_mac(platform, folder, developer_id, notarize, symbols): | |
| 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 | 171 | bundle = folder / 'Snowbound.app' |
| 161 | 172 | if platform == 'macos-10.6': |
| 162 | 173 | signing = ['--snow-leopard'] |
| ... | ... | @@ -166,7 +177,9 @@ def build_mac(platform, folder, developer_id, notarize): |
| 166 | 177 | signing = ['--sign', 'ad-hoc'] |
| 167 | 178 | if platform != 'macos-10.6': |
| 168 | 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 | 183 | archive = folder / 'archive.zip' |
| 171 | 184 | if notarize and platform != 'macos-10.6': |
| 172 | 185 | zip_bundle(bundle, archive) |
| ... | ... | @@ -234,12 +247,16 @@ def main(): |
| 234 | 247 | stage.mkdir(parents=True) |
| 235 | 248 | # The app reads its version from this as it compiles. |
| 236 | 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 | 252 | built = {} |
| 253 | symbols = [] | |
| 238 | 254 | for platform in args.platforms: |
| 239 | 255 | if platform.startswith('macos'): |
| 240 | 256 | work = stage / platform |
| 241 | 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 | 260 | linux = [platform.removeprefix('linux-') for platform in args.platforms if platform.startswith('linux')] |
| 244 | 261 | if linux: |
| 245 | 262 | built |= build_linux(linux) |
| ... | ... | @@ -252,6 +269,12 @@ def main(): |
| 252 | 269 | prefix = 'Snowbound' if platform.startswith('macos') else 'snowbound' |
| 253 | 270 | files[platform] = stage / f'{prefix}-{name(version)}-{platform}{source.suffix}' |
| 254 | 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 | 278 | signatures = sign(files.values()) |
| 256 | 279 | build = { |
| 257 | 280 | 'version': name(version), |
| ... | ... | @@ -270,7 +293,7 @@ def main(): |
| 270 | 293 | partial = target.with_name(f'.{target.name}.partial') |
| 271 | 294 | shutil.rmtree(partial, ignore_errors=True) |
| 272 | 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 | 297 | # copy() keeps the Linux executables executable for anyone running them off the share. |
| 275 | 298 | shutil.copy(file, partial / file.name) |
| 276 | 299 | partial.rename(target) |
tools/test_release.py+6-6| ... | ... | @@ -66,19 +66,19 @@ class ReleaseTest(unittest.TestCase): |
| 66 | 66 | def signing(platform, developer_id, notarize): |
| 67 | 67 | commands.clear() |
| 68 | 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 | 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 | 72 | [command[1] for command in commands[1:]]) |
| 73 | 73 | |
| 74 | 74 | self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']), |
| 75 | 75 | (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'aarch64'], |
| 76 | ['-c', 'notarytool', 'stapler', '-c'])) | |
| 76 | ['-c', '-c', 'notarytool', 'stapler', '-c'])) | |
| 77 | 77 | self.assertEqual(signing('macos-x86_64', True, ['--key-id', 'K']), |
| 78 | 78 | (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'x86_64'], |
| 79 | ['-c', 'notarytool', 'stapler', '-c'])) | |
| 80 | self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc', '--arch', 'aarch64'], ['-c'])) | |
| 81 | self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c'])) | |
| 79 | ['-c', '-c', 'notarytool', 'stapler', '-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', '-c'])) | |
| 82 | 82 | finally: |
| 83 | 83 | scope['run'] = run |
| 84 | 84 |