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
1717triple=$arch-unknown-linux-gnu
1818rustup target add "$triple" >/dev/null
1919variable=$(echo "$triple" | tr - _)
20# The build id ties an executable to its published .debug file.
2021env "CARGO_TARGET_$(echo "$triple" | tr 'a-z-' 'A-Z_')_LINKER=$here/cc.sh" \
2122 "CC_$variable=$here/cc.sh" "AR_$variable=$here/ar.sh" \
2223 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
2929 snowbound-2026-09-29-r10-linux-aarch64
3030 snowbound-2026-09-29-r10-windows-x86_64.exe
3131 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 ...
3236```
3337
3438A 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
154158`.cargo/config.toml` builds every target with frame pointers, and Rust's
155159standard library ships with them. The symbols cost about a fifth: Linux x86_64
156160grows 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, and
158a 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
160tools that read only PDBs see no names: the Rust targets here emit DWARF, from
161which lld's PDB keeps only global symbols. glibc's `backtrace_symbols_fd` reads only
162dynamic symbols, so for a signal Linux's crash log starts the executable again
163with `--symbolize` to name its frames from the symbol table.
161already carried them.
162
163The release builds with line tables, then moves them out of each executable
164into 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
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
165180## In the app
166181
tools/canvas/build_macos.py+4
......@@ -15,6 +15,7 @@ import tempfile
1515parser = argparse.ArgumentParser(description=__doc__)
1616parser.add_argument('--release', action='store_true')
1717parser.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")
1819parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise")
1920host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64'
2021parser.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)
107108pending = binary.with_suffix('.next')
108109shutil.copy2(built / ('release' if args.release else 'debug') / 'snowbound', pending)
109110pending.replace(binary)
111if args.dsym:
112 subprocess.run(['dsymutil', binary, '-o', args.dsym], check=True)
113 subprocess.run(['strip', '-S', binary], check=True)
110114icons = root / 'crates/snowbound/assets/icon'
111115resources = bundle / 'Contents/Resources'
112116resources.mkdir(exist_ok=True)
tools/release.py+28-5
......@@ -11,6 +11,7 @@ import shutil
1111import subprocess
1212import sys
1313import tempfile
14import zipfile
1415from zoneinfo import ZoneInfo
1516
1617ROOT = Path(__file__).resolve().parents[1]
......@@ -141,6 +142,15 @@ def sign(files):
141142 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
144154def zip_bundle(bundle, archive):
145155 run(['ditto', '-c', '-k', '--norsrc', '--noextattr', '--noqtn', '--noacl', '--keepParent', bundle, archive])
146156
......@@ -155,8 +165,9 @@ def notary():
155165 return arguments if signs_in else None
156166
157167
158def 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."""
168def 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`."""
160171 bundle = folder / 'Snowbound.app'
161172 if platform == 'macos-10.6':
162173 signing = ['--snow-leopard']
......@@ -166,7 +177,9 @@ def build_mac(platform, folder, developer_id, notarize):
166177 signing = ['--sign', 'ad-hoc']
167178 if platform != 'macos-10.6':
168179 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)
170183 archive = folder / 'archive.zip'
171184 if notarize and platform != 'macos-10.6':
172185 zip_bundle(bundle, archive)
......@@ -234,12 +247,16 @@ def main():
234247 stage.mkdir(parents=True)
235248 # The app reads its version from this as it compiles.
236249 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'
237252 built = {}
253 symbols = []
238254 for platform in args.platforms:
239255 if platform.startswith('macos'):
240256 work = stage / platform
241257 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])
243260 linux = [platform.removeprefix('linux-') for platform in args.platforms if platform.startswith('linux')]
244261 if linux:
245262 built |= build_linux(linux)
......@@ -252,6 +269,12 @@ def main():
252269 prefix = 'Snowbound' if platform.startswith('macos') else 'snowbound'
253270 files[platform] = stage / f'{prefix}-{name(version)}-{platform}{source.suffix}'
254271 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)
255278 signatures = sign(files.values())
256279 build = {
257280 'version': name(version),
......@@ -270,7 +293,7 @@ def main():
270293 partial = target.with_name(f'.{target.name}.partial')
271294 shutil.rmtree(partial, ignore_errors=True)
272295 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']:
274297 # copy() keeps the Linux executables executable for anyone running them off the share.
275298 shutil.copy(file, partial / file.name)
276299 partial.rename(target)
tools/test_release.py+6-6
......@@ -66,19 +66,19 @@ class ReleaseTest(unittest.TestCase):
6666 def signing(platform, developer_id, notarize):
6767 commands.clear()
6868 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')
7070 build = commands[0]
71 return (build[build.index(f'{stage}/Snowbound.app') + 1:],
71 return (build[build.index(f'{stage}/Snowbound.dSYM') + 1:],
7272 [command[1] for command in commands[1:]])
7373
7474 self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']),
7575 (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'aarch64'],
76 ['-c', 'notarytool', 'stapler', '-c']))
76 ['-c', '-c', 'notarytool', 'stapler', '-c']))
7777 self.assertEqual(signing('macos-x86_64', True, ['--key-id', 'K']),
7878 (['--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']))
8282 finally:
8383 scope['run'] = run
8484