Skip to content

Architecture

binpatch is a small library by design. This page documents the design decisions and trade-offs so contributors don’t accidentally regress them.

flowchart LR
Code["your code"] -->|"call"| RnA["resolveAndApply"]
RnA --> SS["SourceStrategy<br/>.resolveChain()<br/>(pluggable)"]
RnA --> F1["fetch patches"]
F1 --> F2["apply each hop"]
F2 --> F3["verify SHA-256"]
F3 --> F4["emit ProgressEvents"]
F4 -->|"return result"| Code

resolveAndApply is the single entry point for the discovery + apply flow. The pure apply functions (applyPatchChainInMemory, etc.) are exposed for callers who want to do their own discovery.

The library is split into four concerns:

  1. Apply (src/bspatch.ts, ~770 lines) — TRDIFF10 parse + apply. Pure logic, no I/O. applyPatchChainInMemory is the only entry point most consumers need.
  2. Cache (src/patch-cache.ts, ~430 lines) — on-disk patch cache keyed by (fromVersion, toVersion). Cache directory is injected via makeCache(cacheDir) — the library never reads config files itself.
  3. Discover (src/discover.ts, src/sources/*) — SourceStrategy abstraction with ghcrSource and githubReleaseSource built-ins. Pluggable for custom registries.
  4. Events (src/events.ts, ~50 lines) — ProgressEvent stream emitted during resolveAndApply. Library never renders progress — consumers plug in their own indicator.

A small index.ts barrel exports the public surface. The contract.ts file holds shared types (constants, chain limits, etc.).

The dominant cost in applyPatch is the diff-add loop: XOR the diff block into the destination window. We use SWAR (SIMD-within-a-register) to process 4 bytes at a time on 32-bit words:

// 32-bit SWAR: 4 bytes added in one cycle
const mask = 0x7f7f7f7f;
const sign = 0x80808080;
((a & mask) + (b & mask)) ^ ((a ^ b) & sign);

For a 100 MB binary where ~99% of diff blocks are zero-dominated and bsdiff has already crushed them into the high 8-12 KB of the patch, this 4-byte-per-cycle approach takes ~145 ms vs ~280 ms for the naive byte loop on the same machine (~1.9× faster). Real numbers from the Apply → Performance benchmark.

We do not use BigUint64Array for SWAR. The 64-bit carry rule is not actually broken — the 0x7f mask strips each byte’s high bit before the add, so the masked add carries within each byte only, and the trick is correct at any lane width. (Verified across 1.7M+ random pairs and worst-case carry patterns.) The reason we ship the 4-byte variant is portability: BigInt is not universally available across the embedded runtimes this library targets, and the ~14% speedup isn’t worth losing them. A byteOffset % 4 alignment guard falls back to the byte loop when the buffer is not 4-byte aligned — keeps the SWAR fast path safe.

Apply: why not Courgette-style executable-aware diffing?

Section titled “Apply: why not Courgette-style executable-aware diffing?”

Courgette was a Chromium component that normalized pointer relocations in machine code before diffing, giving Chrome substantial patch-size wins on full Chromium updates. It has since been retired; the live successor is Zucchini. We do not use either because:

  • The bsdiff seek mechanism already collapses most pointer churn into small seek distances. For our use case the bulk of the binary is a JS snapshot, which has little of the native-code relocation churn that Courgette/Zucchini target — so the normalization step has little to bite on.
  • Courgette/Zucchini require understanding the executable format in detail (PE/ELF/Mach-O + relocations). Supporting all three platforms we ship for (linux/darwin/windows) would multiply maintenance cost.
  • For our use case the bottleneck is full-download fallback latency, not patch size. Patch download is small; the cost is the cold path when no chain is available.

If you ship embedded native code with heavy relocation churn, or your binary is small enough that even the bsdiff patch is the bottleneck, consider Zucchini or bsdiff-mantissa. For binaries where the bulk is a JS snapshot (Node SEA, Bun --compile, Deno compile), bsdiff + SWAR is the right trade.

We considered bundling a native mmap-based reader for the old binary to avoid loading it fully into RAM. We chose not to because:

  • mmap via bun:ffi is not yet portable across the runtimes this library targets (Node and Bun).
  • mmap-io and similar native addons break esbuild + Node SEA bundling, which several shipped consumers rely on.
  • For a 100 MB binary, an in-memory Uint8Array is fine: it’s about 1% of a typical CI runner’s RAM budget.

If your binary is much larger (>1 GB), the public applyPatchChainInMemory is still the right entry point — it reads the on-disk base via positional reads (pread) on demand, so a 4 GB binary uses ~1 MiB of read-ahead buffer rather than 4 GiB of RAM. You only need a custom reader if you want to apply from a stream (network-mounted storage, an archive, etc.); that’s an internal seam today, not a public export.

Discovery is the most consumer-specific part of the upgrade flow: nightly vs stable, GHCR vs custom OCI, GitHub Releases vs S3, etc. Pushing that into the library would force every consumer to either accept a default they can’t change or reimplement it themselves.

SourceStrategy.resolveChain(currentVersion, targetVersion, signal?, report?) returns a PatchChain (or null). The library handles the chain validation, ordering, and per-hop download. Consumers ship a 30-line strategy object and get the rest.

Discover: why both GHCR and GitHub Releases?

Section titled “Discover: why both GHCR and GitHub Releases?”

Same library, two channels:

  • Nightly / canary: stored in GHCR (or any OCI registry). OCI gives us content-addressable storage, multi-arch, and the :nightly:nightly-<version> zero-copy tag pattern. Standard tooling (oras, docker, crane) can interact with it.
  • Stable / released: stored in GitHub Releases. Native UI for signed releases, asset downloads via gh release download. Stable channels typically have fewer versions and don’t benefit from OCI machinery.

The two channels share the wire format and the apply core — they only differ in discovery.

resolveAndApply emits ProgressEvents ({ type: "phase", phase }, { type: "bytes", phase, written, total }, { type: "done", phase }). It does not emit OpenTelemetry / Sentry / StatsD spans. That’s deliberate: span shape varies wildly between consumers (Sentry’s per-HTTP withTracingSpan, OTel’s startActiveSpan, StatsD’s timing distribution). Consumers wire the events into whatever their tracing system is.

For per-HTTP-step granularity, an InstrumentHook lets you wrap each OCI request in your own span without leaking fetch details into the library. See Instrumentation.