55 * TRDIFF10 format (produced by zig-bsdiff with `--use-zstd`). Designed for
66 * minimal memory usage during CLI self-upgrades:
77 *
8- * - Old binary: copy-then-mmap for 0 JS heap (CoW on btrfs/xfs/APFS),
9- * falling back to `arrayBuffer()` if copy/mmap fails
10- * - Diff/extra blocks: streamed via `DecompressionStream('zstd')`
11- * - Output: written incrementally to disk via `Bun.file().writer()`
12- * - Integrity: SHA-256 computed inline via `Bun.CryptoHasher`
13- *
14- * `Bun.mmap()` cannot target the running binary directly because it opens
15- * with PROT_WRITE/O_RDWR:
16- * - macOS: AMFI sends uncatchable SIGKILL (writable mapping on signed Mach-O)
17- * - Linux: ETXTBSY from `open()` (kernel blocks write-open on running ELF)
18- *
19- * The copy-then-mmap strategy sidesteps both: the copy is a regular file
20- * with no running process, so mmap succeeds. On CoW-capable filesystems
21- * (btrfs, xfs, APFS) the copy is near-instant with zero extra disk I/O.
8+ * - Old binary: copy to temp file, then read via `readFile()` (~100 MB heap)
9+ * - Diff/extra blocks: streamed via zstd `Transform` from `node:zlib`
10+ * - Output: written incrementally to disk via `createWriteStream()`
11+ * - Integrity: SHA-256 computed inline via `node:crypto`
2212 *
2313 * TRDIFF10 format (from zig-bsdiff):
2414 * ```
3020 * ```
3121 */
3222
33- import { constants , copyFileSync } from "node:fs" ;
23+ import { createHash } from "node:crypto" ;
24+ import { constants , copyFileSync , createWriteStream } from "node:fs" ;
3425import { readFile , unlink } from "node:fs/promises" ;
3526import { tmpdir } from "node:os" ;
3627import { join } from "node:path" ;
28+ import { Readable } from "node:stream" ;
29+ import { createZstdDecompress , zstdDecompressSync } from "node:zlib" ;
3730
3831/** TRDIFF10 header magic bytes */
3932const TRDIFF10_MAGIC = "TRDIFF10" ;
@@ -123,7 +116,7 @@ export function parsePatchHeader(patch: Uint8Array): PatchHeader {
123116/**
124117 * Buffered reader over a `ReadableStream` that serves exact byte counts.
125118 *
126- * Wraps a `DecompressionStream` output reader to provide `read(n)` semantics:
119+ * Wraps a decompression stream output reader to provide `read(n)` semantics:
127120 * pulls chunks from the underlying stream as needed, buffers leftover bytes,
128121 * and returns exactly `n` bytes per call.
129122 */
@@ -195,34 +188,41 @@ class BufferedStreamReader {
195188/**
196189 * Create a streaming zstd decompressor from a compressed buffer.
197190 *
198- * Wraps the compressed data in a ReadableStream, pipes through
199- * DecompressionStream('zstd'), and returns a BufferedStreamReader
200- * for on-demand byte consumption.
191+ * Pipes the compressed data through `node:zlib`'s zstd decompressor and
192+ * returns a BufferedStreamReader for on-demand byte consumption.
201193 *
202194 * @param compressed - Zstd-compressed data
203195 * @returns BufferedStreamReader for incremental decompression
204196 */
205197function createZstdStreamReader ( compressed : Uint8Array ) : BufferedStreamReader {
206- const input = new ReadableStream < Uint8Array > ( {
198+ // Convert the node:zlib Transform stream into a Web ReadableStream
199+ // so BufferedStreamReader can consume it with the same interface.
200+ const nodeStream = Readable . from ( Buffer . from ( compressed ) ) . pipe (
201+ createZstdDecompress ( )
202+ ) ;
203+
204+ const webStream = new ReadableStream < Uint8Array > ( {
207205 start ( controller ) {
208- controller . enqueue ( compressed ) ;
209- controller . close ( ) ;
206+ nodeStream . on ( "data" , ( chunk : Buffer ) => {
207+ controller . enqueue ( new Uint8Array ( chunk ) ) ;
208+ } ) ;
209+ nodeStream . on ( "end" , ( ) => {
210+ controller . close ( ) ;
211+ } ) ;
212+ nodeStream . on ( "error" , ( err ) => {
213+ controller . error ( err ) ;
214+ } ) ;
210215 } ,
211216 } ) ;
212217
213- // Bun supports 'zstd' but the standard CompressionFormat type doesn't include it
214- const decompressed = input . pipeThrough (
215- new DecompressionStream ( "zstd" as "deflate" )
216- ) ;
217-
218218 return new BufferedStreamReader (
219- decompressed . getReader ( ) as ReadableStreamDefaultReader < Uint8Array >
219+ webStream . getReader ( ) as ReadableStreamDefaultReader < Uint8Array >
220220 ) ;
221221}
222222
223223/** Result of loading the old binary for patching */
224224type OldFileHandle = {
225- /** Memory-mapped or in -memory view of the old binary */
225+ /** In -memory view of the old binary */
226226 data : Uint8Array ;
227227 /** Cleanup function to call after patching (removes temp copy, if any) */
228228 cleanup : ( ) => void | Promise < void > ;
@@ -231,14 +231,10 @@ type OldFileHandle = {
231231/**
232232 * Load the old binary for read access during patching.
233233 *
234- * Strategy: copy to temp file, then try mmap on the copy. The copy is a
235- * regular file (no running process), so `Bun.mmap()` succeeds on both
236- * Linux and macOS — ETXTBSY (Linux) and AMFI SIGKILL (macOS) only affect
237- * the running binary's inode, not a copy. On CoW filesystems (btrfs, xfs,
238- * APFS) the copy is a metadata-only reflink (near-instant).
239- *
240- * Falls back to `Bun.file().arrayBuffer()` (~100 MB heap) if copy or
241- * mmap fails for any reason.
234+ * Strategy: copy to temp file, then read into memory. The copy avoids
235+ * ETXTBSY (Linux) / AMFI SIGKILL (macOS) issues with reading the running
236+ * binary directly. On CoW filesystems (btrfs, xfs, APFS) the copy is a
237+ * metadata-only reflink (near-instant).
242238 */
243239let loadCounter = 0 ;
244240
@@ -253,18 +249,15 @@ async function loadOldBinary(oldPath: string): Promise<OldFileHandle> {
253249 // silently falls back to regular copy on filesystems that don't support it.
254250 copyFileSync ( oldPath , tempCopy , constants . COPYFILE_FICLONE ) ;
255251
256- // mmap the copy — safe because it's a separate inode, not the running
257- // binary. MAP_PRIVATE avoids write-back to disk.
258- const data = Bun . mmap ( tempCopy , { shared : false } ) ;
259252 return {
260- data,
253+ data : new Uint8Array ( await readFile ( tempCopy ) ) ,
261254 cleanup : ( ) =>
262255 unlink ( tempCopy ) . catch ( ( ) => {
263256 /* Best-effort cleanup — OS will reclaim on reboot */
264257 } ) ,
265258 } ;
266259 } catch {
267- // Copy or mmap failed — fall back to reading into JS heap
260+ // Copy failed — read directly into JS heap
268261 await unlink ( tempCopy ) . catch ( ( ) => {
269262 /* May not exist if copyFileSync failed */
270263 } ) ;
@@ -280,10 +273,9 @@ async function loadOldBinary(oldPath: string): Promise<OldFileHandle> {
280273/**
281274 * Apply a TRDIFF10 binary patch with streaming I/O for minimal memory usage.
282275 *
283- * Copies the old file to a temp path and mmaps the copy (0 JS heap), falling
284- * back to `arrayBuffer()` if mmap fails. Streams diff/extra blocks via
285- * `DecompressionStream('zstd')`, writes output via `Bun.file().writer()`,
286- * and computes SHA-256 inline.
276+ * Copies the old file to a temp path and reads it into memory. Streams
277+ * diff/extra blocks via `node:zlib` zstd decompressor, writes output via
278+ * `createWriteStream()`, and computes SHA-256 inline.
287279 *
288280 * @param oldPath - Path to the existing (old) binary file
289281 * @param patchData - Complete TRDIFF10 patch file contents
@@ -304,7 +296,7 @@ export async function applyPatch(
304296 const extraStart = diffStart + diffLen ;
305297
306298 // Control block is tiny — decompress fully for random access to tuples
307- const controlBlock = Bun . zstdDecompressSync (
299+ const controlBlock = zstdDecompressSync (
308300 patchData . subarray ( controlStart , diffStart )
309301 ) ;
310302
@@ -314,14 +306,20 @@ export async function applyPatch(
314306 ) ;
315307 const extraReader = createZstdStreamReader ( patchData . subarray ( extraStart ) ) ;
316308
317- // Load old binary via copy-then-mmap (0 JS heap) or arrayBuffer fallback.
318- // See loadOldBinary() for why direct mmap of the running binary is impossible.
309+ // Load old binary via copy-then-read (or direct read as fallback).
319310 const { data : oldFile , cleanup : cleanupOldFile } =
320311 await loadOldBinary ( oldPath ) ;
321312
322- // Streaming output: write directly to disk, no output buffer in memory
323- const writer = Bun . file ( destPath ) . writer ( ) ;
324- const hasher = new Bun . CryptoHasher ( "sha256" ) ;
313+ // Streaming output: write directly to disk, compute SHA-256 inline
314+ const writer = createWriteStream ( destPath ) ;
315+ const hasher = createHash ( "sha256" ) ;
316+
317+ // Capture write errors early — without a listener, Node crashes with
318+ // ERR_UNHANDLED_ERROR if a write fails (ENOSPC, EIO, etc.) during the loop.
319+ let writeError : Error | undefined ;
320+ writer . on ( "error" , ( err ) => {
321+ writeError ??= err ;
322+ } ) ;
325323
326324 let oldpos = 0 ;
327325 let newpos = 0 ;
@@ -333,6 +331,9 @@ export async function applyPatch(
333331 controlPos < controlBlock . byteLength ;
334332 controlPos += 24
335333 ) {
334+ if ( writeError ) {
335+ break ;
336+ }
336337 const readDiffBy = offtin ( controlBlock , controlPos ) ;
337338 const readExtraBy = offtin ( controlBlock , controlPos + 8 ) ;
338339 const seekBy = offtin ( controlBlock , controlPos + 16 ) ;
@@ -367,7 +368,16 @@ export async function applyPatch(
367368 }
368369 } finally {
369370 try {
370- await writer . end ( ) ;
371+ await new Promise < void > ( ( resolve , reject ) => {
372+ writer . end ( ( err ?: Error | null ) => {
373+ const finalErr = err ?? writeError ;
374+ if ( finalErr ) {
375+ reject ( finalErr ) ;
376+ } else {
377+ resolve ( ) ;
378+ }
379+ } ) ;
380+ } ) ;
371381 } finally {
372382 await cleanupOldFile ( ) ;
373383 }
@@ -380,5 +390,5 @@ export async function applyPatch(
380390 ) ;
381391 }
382392
383- return hasher . digest ( "hex" ) as string ;
393+ return hasher . digest ( "hex" ) ;
384394}
0 commit comments