authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 17:43:38-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 19:23:37-07:00
logd9fc5cc413b64a5ce5699007860952ea911fded2
treef6d3fe0e0cfa52c34ad015f3bb00116f81354fc2
parent1e031bc5ce597a74ec16e0cb1e609d173bec91f7
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

5 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
17triple=$arch-unknown-linux-gnu17triple=$arch-unknown-linux-gnu
18rustup target add "$triple" >/dev/null18rustup target add "$triple" >/dev/null
19variable=$(echo "$triple" | tr - _)19variable=$(echo "$triple" | tr - _)
20# The build id ties an executable to its published .debug file.
20env "CARGO_TARGET_$(echo "$triple" | tr 'a-z-' 'A-Z_')_LINKER=$here/cc.sh" \21env "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-aarch6429 snowbound-2026-09-29-r10-linux-aarch64
30 snowbound-2026-09-29-r10-windows-x86_64.exe30 snowbound-2026-09-29-r10-windows-x86_64.exe
31 snowbound-2026-09-29-r10-windows-aarch64.exe31 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```
3337
34A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into38A 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's158`.cargo/config.toml` builds every target with frame pointers, and Rust's
155standard library ships with them. The symbols cost about a fifth: Linux x86_64159standard library ships with them. The symbols cost about a fifth: Linux x86_64
156grows from 53 to 62 MB, Windows x86_64 from 55 to 72 MB, and the macOS app160grows from 53 to 62 MB, Windows x86_64 from 55 to 72 MB, and the macOS app
157already carried them. Line tables would take an executable to some 250 MB, and161already carried them.
158a dSYM adds 34 MB zipped per Mac architecture, so neither ships; build with162
159`CARGO_PROFILE_RELEASE_DEBUG=line-tables-only` for files and lines. Windows163The release builds with line tables, then moves them out of each executable
160tools that read only PDBs see no names: the Rust targets here emit DWARF, from164into the build folder's zipped symbol files, which neither `latest.json` nor
161which lld's PDB keeps only global symbols. glibc's `backtrace_symbols_fd` reads only165`build.json` names, so the app never downloads them: a dSYM for each Mac
162dynamic symbols, so for a signal Linux's crash log starts the executable again166(matched by its UUID; `build_macos.py --dsym`), and for Linux and Windows a
163with `--symbolize` to name its frames from the symbol table.167DWARF `.debug` file, which the executable names in its `.gnu_debuglink` (and on
168Linux by its build id). Unzipped beside the executable, or the dSYM beside the
169app, they give lldb, gdb, `perf`, Instruments and `llvm-symbolizer` files,
170lines and inlined calls. They run about 35 MB zipped per Mac, 55 MB per Linux
171and 40 MB per Windows architecture. Windows gets DWARF rather than a PDB: rustc
172emits CodeView only for MSVC targets, and lld builds a PDB's functions and lines
173from CodeView alone, so from these DWARF objects its PDB holds only the global
174symbols, which few Rust functions are; WPA and Visual Studio see no names.
175
176glibc's `backtrace_symbols_fd` reads only dynamic symbols, so for a signal
177Linux's crash log starts the executable again with `--symbolize` to name its
178frames from the symbol table.
164179
165## In the app180## In the app
166181
tools/canvas/build_macos.py+4
...@@ -15,6 +15,7 @@ import tempfile...@@ -15,6 +15,7 @@ import tempfile
15parser = argparse.ArgumentParser(description=__doc__)15parser = argparse.ArgumentParser(description=__doc__)
16parser.add_argument('--release', action='store_true')16parser.add_argument('--release', action='store_true')
17parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path')17parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path')
18parser.add_argument('--dsym', type=Path, help="Move the executable's debug info into a dSYM at this path")
18parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise")19parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise")
19host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64'20host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64'
20parser.add_argument('--arch', choices=['aarch64', 'x86_64'], default=host, help="The app's architecture; this Mac's by default")21parser.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)
107pending = binary.with_suffix('.next')108pending = binary.with_suffix('.next')
108shutil.copy2(built / ('release' if args.release else 'debug') / 'snowbound', pending)109shutil.copy2(built / ('release' if args.release else 'debug') / 'snowbound', pending)
109pending.replace(binary)110pending.replace(binary)
111if args.dsym:
112 subprocess.run(['dsymutil', binary, '-o', args.dsym], check=True)
113 subprocess.run(['strip', '-S', binary], check=True)
110icons = root / 'crates/snowbound/assets/icon'114icons = root / 'crates/snowbound/assets/icon'
111resources = bundle / 'Contents/Resources'115resources = bundle / 'Contents/Resources'
112resources.mkdir(exist_ok=True)116resources.mkdir(exist_ok=True)
tools/release.py+28-5
...@@ -11,6 +11,7 @@ import shutil...@@ -11,6 +11,7 @@ import shutil
11import subprocess11import subprocess
12import sys12import sys
13import tempfile13import tempfile
14import zipfile
14from zoneinfo import ZoneInfo15from zoneinfo import ZoneInfo
1516
16ROOT = Path(__file__).resolve().parents[1]17ROOT = 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()
142143
143144
145def 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
144def zip_bundle(bundle, archive):154def 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])
146156
...@@ -155,8 +165,9 @@ def notary():...@@ -155,8 +165,9 @@ def notary():
155 return arguments if signs_in else None165 return arguments if signs_in else None
156166
157167
158def build_mac(platform, folder, developer_id, notarize):168def 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 / platform256 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:]])
7373
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'] = run83 scope['run'] = run
8484