|
5 | 5 | * TRDIFF10 format (produced by zig-bsdiff with `--use-zstd`). Designed for |
6 | 6 | * minimal memory usage during CLI self-upgrades: |
7 | 7 | * |
8 | | - * - Old binary: loaded via `Bun.file().arrayBuffer()` (~100 MB heap) |
| 8 | + * - Old binary: copy-then-mmap for 0 JS heap (CoW on btrfs/xfs/APFS), |
| 9 | + * with child-process probe for mmap safety (macOS AMFI sends uncatchable |
| 10 | + * SIGKILL on signed Mach-O), falling back to `arrayBuffer()` if unsafe |
9 | 11 | * - Diff/extra blocks: streamed via `DecompressionStream('zstd')` |
10 | 12 | * - Output: written incrementally to disk via `Bun.file().writer()` |
11 | 13 | * - Integrity: SHA-256 computed inline via `Bun.CryptoHasher` |
12 | 14 | * |
13 | | - * Total heap usage: ~100 MB for old file + ~1-2 MB for streaming buffers. |
14 | | - * `Bun.mmap()` is NOT usable here because the old file is the running binary: |
15 | | - * - macOS: AMFI sends uncatchable SIGKILL (PROT_WRITE on signed Mach-O) |
16 | | - * - Linux: ETXTBSY from `open()` with write flags on a running executable |
| 15 | + * `Bun.mmap()` cannot target the running binary directly because it opens |
| 16 | + * with PROT_WRITE/O_RDWR: |
| 17 | + * - macOS: AMFI sends uncatchable SIGKILL (writable mapping on signed Mach-O) |
| 18 | + * - Linux: ETXTBSY from `open()` (kernel blocks write-open on running ELF) |
| 19 | + * |
| 20 | + * The copy-then-mmap strategy sidesteps both: the copy is a regular file |
| 21 | + * with no running process, so mmap succeeds. On CoW-capable filesystems |
| 22 | + * (btrfs, xfs, APFS) the copy is near-instant with zero extra disk I/O. |
17 | 23 | * |
18 | 24 | * TRDIFF10 format (from zig-bsdiff): |
19 | 25 | * ``` |
|
25 | 31 | * ``` |
26 | 32 | */ |
27 | 33 |
|
| 34 | +import { copyFileSync, unlinkSync } from "node:fs"; |
| 35 | +import { tmpdir } from "node:os"; |
| 36 | +import { join } from "node:path"; |
| 37 | + |
28 | 38 | /** TRDIFF10 header magic bytes */ |
29 | 39 | const TRDIFF10_MAGIC = "TRDIFF10"; |
30 | 40 |
|
@@ -210,12 +220,117 @@ function createZstdStreamReader(compressed: Uint8Array): BufferedStreamReader { |
210 | 220 | ); |
211 | 221 | } |
212 | 222 |
|
| 223 | +/** Result of loading the old binary for patching */ |
| 224 | +type OldFileHandle = { |
| 225 | + /** Memory-mapped or in-memory view of the old binary */ |
| 226 | + data: Uint8Array; |
| 227 | + /** Cleanup function to call after patching (removes temp copy, if any) */ |
| 228 | + cleanup: () => void; |
| 229 | +}; |
| 230 | + |
| 231 | +/** |
| 232 | + * Probe whether `Bun.mmap()` works on a file by spawning a child process. |
| 233 | + * |
| 234 | + * macOS AMFI sends uncatchable SIGKILL when a process creates a writable |
| 235 | + * memory mapping of a code-signed Mach-O binary — even a copy. Since |
| 236 | + * SIGKILL terminates the process inside the syscall (before any catch |
| 237 | + * block runs), we cannot detect the failure in-process. |
| 238 | + * |
| 239 | + * This probe spawns a minimal child that attempts `Bun.mmap()` on the |
| 240 | + * target file. If the child exits 0, mmap is safe. If it's killed |
| 241 | + * (SIGKILL) or exits non-zero, we fall back to `arrayBuffer()`. |
| 242 | + * |
| 243 | + * @param filePath - Path to the file to probe (should be the temp copy) |
| 244 | + * @returns true if mmap is safe on this file, false otherwise |
| 245 | + */ |
| 246 | +async function probeMmapSafe(filePath: string): Promise<boolean> { |
| 247 | + try { |
| 248 | + const child = Bun.spawn( |
| 249 | + [ |
| 250 | + process.execPath, |
| 251 | + "-e", |
| 252 | + `Bun.mmap(${JSON.stringify(filePath)}, { shared: false })`, |
| 253 | + ], |
| 254 | + { stdout: "ignore", stderr: "ignore" } |
| 255 | + ); |
| 256 | + const exitCode = await child.exited; |
| 257 | + return exitCode === 0; |
| 258 | + } catch { |
| 259 | + return false; |
| 260 | + } |
| 261 | +} |
| 262 | + |
| 263 | +/** |
| 264 | + * Load the old binary for read access during patching. |
| 265 | + * |
| 266 | + * Strategy: copy to temp file, probe mmap safety via child process, |
| 267 | + * then mmap the copy if safe. This avoids `Bun.mmap()` on files that |
| 268 | + * would trigger uncatchable SIGKILL (macOS AMFI on signed Mach-O) while |
| 269 | + * keeping zero JS heap on platforms where mmap works. On CoW filesystems |
| 270 | + * (btrfs, xfs, APFS) the copy is a metadata-only reflink (near-instant). |
| 271 | + * |
| 272 | + * Falls back to `Bun.file().arrayBuffer()` (~100 MB heap) if the probe |
| 273 | + * fails or if copy/mmap errors for any reason. |
| 274 | + */ |
| 275 | +async function loadOldBinary(oldPath: string): Promise<OldFileHandle> { |
| 276 | + const tempCopy = join(tmpdir(), `sentry-patch-old-${process.pid}`); |
| 277 | + try { |
| 278 | + copyFileSync(oldPath, tempCopy); |
| 279 | + |
| 280 | + // Probe mmap safety in a child process — macOS AMFI sends uncatchable |
| 281 | + // SIGKILL on writable mappings of signed Mach-O, even for copies. |
| 282 | + const mmapSafe = await probeMmapSafe(tempCopy); |
| 283 | + |
| 284 | + if (mmapSafe) { |
| 285 | + const data = Bun.mmap(tempCopy, { shared: false }); |
| 286 | + return { |
| 287 | + data, |
| 288 | + cleanup: () => { |
| 289 | + try { |
| 290 | + unlinkSync(tempCopy); |
| 291 | + } catch { |
| 292 | + // Best-effort cleanup — OS will reclaim on reboot |
| 293 | + } |
| 294 | + }, |
| 295 | + }; |
| 296 | + } |
| 297 | + |
| 298 | + // mmap probe failed — read copy into JS heap, then clean up temp file |
| 299 | + const data = new Uint8Array(await Bun.file(tempCopy).arrayBuffer()); |
| 300 | + try { |
| 301 | + unlinkSync(tempCopy); |
| 302 | + } catch { |
| 303 | + // Best-effort cleanup |
| 304 | + } |
| 305 | + return { |
| 306 | + data, |
| 307 | + cleanup: () => { |
| 308 | + // Data is in JS heap — no temp file to clean up |
| 309 | + }, |
| 310 | + }; |
| 311 | + } catch { |
| 312 | + // Copy failed — fall back to reading original directly into JS heap |
| 313 | + try { |
| 314 | + unlinkSync(tempCopy); |
| 315 | + } catch { |
| 316 | + // May not exist if copyFileSync failed |
| 317 | + } |
| 318 | + return { |
| 319 | + data: new Uint8Array(await Bun.file(oldPath).arrayBuffer()), |
| 320 | + cleanup: () => { |
| 321 | + // Data is in JS heap — no temp file to clean up |
| 322 | + }, |
| 323 | + }; |
| 324 | + } |
| 325 | +} |
| 326 | + |
213 | 327 | /** |
214 | 328 | * Apply a TRDIFF10 binary patch with streaming I/O for minimal memory usage. |
215 | 329 | * |
216 | | - * Reads the old file into memory via `Bun.file().arrayBuffer()`, then streams |
217 | | - * diff/extra blocks (~16 KB buffers) via `DecompressionStream('zstd')`, |
218 | | - * writes output via `Bun.file().writer()`, and computes SHA-256 inline. |
| 330 | + * Copies the old file to a temp path and mmaps the copy (0 JS heap), falling |
| 331 | + * back to `arrayBuffer()` if mmap fails. Streams diff/extra blocks via |
| 332 | + * `DecompressionStream('zstd')`, writes output via `Bun.file().writer()`, |
| 333 | + * and computes SHA-256 inline. |
219 | 334 | * |
220 | 335 | * @param oldPath - Path to the existing (old) binary file |
221 | 336 | * @param patchData - Complete TRDIFF10 patch file contents |
@@ -246,12 +361,10 @@ export async function applyPatch( |
246 | 361 | ); |
247 | 362 | const extraReader = createZstdStreamReader(patchData.subarray(extraStart)); |
248 | 363 |
|
249 | | - // Bun.mmap() is NOT usable for the old file during self-upgrades because |
250 | | - // it always opens with PROT_WRITE, and the old file is the running binary: |
251 | | - // - macOS: AMFI sends uncatchable SIGKILL on writable mapping of signed Mach-O |
252 | | - // - Linux: open() returns ETXTBSY when opening a running executable for write |
253 | | - // Reading into memory costs ~100 MB heap but avoids both platform restrictions. |
254 | | - const oldFile = new Uint8Array(await Bun.file(oldPath).arrayBuffer()); |
| 364 | + // Load old binary via copy-then-mmap (0 JS heap) or arrayBuffer fallback. |
| 365 | + // See loadOldBinary() for why direct mmap of the running binary is impossible. |
| 366 | + const { data: oldFile, cleanup: cleanupOldFile } = |
| 367 | + await loadOldBinary(oldPath); |
255 | 368 |
|
256 | 369 | // Streaming output: write directly to disk, no output buffer in memory |
257 | 370 | const writer = Bun.file(destPath).writer(); |
@@ -300,7 +413,11 @@ export async function applyPatch( |
300 | 413 | oldpos += seekBy; |
301 | 414 | } |
302 | 415 | } finally { |
303 | | - await writer.end(); |
| 416 | + try { |
| 417 | + await writer.end(); |
| 418 | + } finally { |
| 419 | + cleanupOldFile(); |
| 420 | + } |
304 | 421 | } |
305 | 422 |
|
306 | 423 | // Validate output size matches header |
|
0 commit comments