Part 4: Shorebird | The Science Behind Shorebird — How a .so File Becomes a Running App

The Science Behind Shorebird — How a .so File Becomes a Running App

The Science Behind Shorebird

How replacing a file on disk makes a new button appear on screen. No code — just the underlying computer science, explained from first principles.

Chapter 1: What IS libapp.so?

Before understanding how patching works, you need to understand what libapp.so actually is. It's not "code" in the way you think of it.

Your Dart code is not interpreted

When you write Flutter in Dart, you write human-readable text:

ElevatedButton(
  onPressed: () => print("Hello"),
  child: Text("Click me"),
)

In debug mode, this text is compiled to an intermediate format and interpreted by the Dart VM on the fly — that's why hot reload works.

But in release mode (which is what users run), something completely different happens. The Dart compiler (gen_snapshot) converts your entire Dart program into native machine code — the same kind of binary instructions that C++ or Rust compiles to. This is called AOT (Ahead-of-Time) compilation.

📖

Analogy: Think of your Dart code as a recipe written in English. Debug mode is like having a chef who reads the recipe out loud and follows it step by step (slow, but flexible — you can change the recipe while cooking). Release mode is like translating the entire recipe into a robot's programming language and burning it onto a chip. The robot executes it at machine speed, but you can't change it without making a new chip.

The output of this compilation is libapp.so — a standard Linux shared library (ELF format) containing your entire app as machine code.

What's inside libapp.so

libapp.so contains exactly 4 things, stored as named symbols (like chapters in a book):

┌──────────────────────────────────────────────────────────────┐
│                    libapp.so (ELF file, ~3 MB)               │
│                                                              │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │  Symbol: kDartVmSnapshotData                            │ │
│  │  Contains: Dart VM initialization data                  │ │
│  │  (Type tables, class hierarchy, GC metadata)            │ │
│  │  Size: ~50 KB                                           │ │
│  └─────────────────────────────────────────────────────────┘ │
│                                                              │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │  Symbol: kDartVmSnapshotInstructions                    │ │
│  │  Contains: Core VM machine code                         │ │
│  │  (Garbage collector, object allocator, core runtime)    │ │
│  │  Size: ~200 KB                                          │ │
│  └─────────────────────────────────────────────────────────┘ │
│                                                              │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │  Symbol: kDartIsolateSnapshotData                       │ │
│  │  Contains: YOUR APP'S DATA                              │ │
│  │  - All string literals ("Click me", "Hello")            │ │
│  │  - All constant objects and values                      │ │
│  │  - The object graph (how objects reference each other)  │ │
│  │  - Class field layouts                                  │ │
│  │  Size: ~500 KB – 1.5 MB                                │ │
│  └─────────────────────────────────────────────────────────┘ │
│                                                              │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │  Symbol: kDartIsolateSnapshotInstructions               │ │
│  │  Contains: YOUR APP'S MACHINE CODE                      │ │
│  │  - Every function you wrote, compiled to ARM64/x86      │ │
│  │  - Every widget build() method                          │ │
│  │  - Every onPressed callback                             │ │
│  │  - Every calculation, every if/else branch              │ │
│  │  - All imported package code (provider, bloc, etc.)     │ │
│  │  Size: ~1 – 2 MB                                       │ │
│  └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Key Insight
libapp.so is a complete snapshot of your entire Dart program frozen into machine code + data. It contains everything — your widgets, your logic, your strings, your constants, and all the package code you imported. When you change one line of Dart, a new libapp.so is generated with different machine code in the instructions section and possibly different data in the data section.

Chapter 2: How Does a .so File Become a Running App?

When your app starts, the Flutter engine needs to turn that libapp.so file into an actual running application. This happens through a process called dynamic loading.

Step 1: The OS loads the file into memory

The Flutter engine calls a function called dlopen() — this is an operating system function that loads a .so file from disk into the process's memory space.

// This is literally the code in the Flutter engine:
handle_ = ::dlopen(path, RTLD_NOW);

// path = "/data/data/com.app/lib/arm64/libapp.so"
//    OR = "/data/data/com.app/files/shorebird_updater/patches/3/dlc.vmcode"
//
// The OS doesn't care which path — it loads whatever file is at that path.
📀

Analogy: Think of dlopen() like putting a CD into a CD player. The player doesn't care which CD you insert — it just reads whatever is on the disc. dlopen() doesn't care which .so file you give it — it just reads whatever is at that file path into memory. Shorebird works by swapping which CD is in the player before the music starts.

What does dlopen() actually do at the OS level?

1. Opens the file and reads the ELF header (first 64 bytes) to understand the file layout.

2. Memory-maps the file's sections. This means the OS creates virtual memory pages that point directly to the file on disk. The file isn't "copied" into RAM — instead, the OS sets up page table entries so that when the CPU accesses those memory addresses, the OS loads the corresponding bytes from the file on demand. This is called mmap().

3. Marks permissions: The instructions sections (machine code) are marked as executable memory — the CPU is allowed to jump to these addresses and execute them. The data sections are marked as read-only — the CPU can read them but not execute them.

4. Returns a handle — a pointer that the engine can use to look up symbols inside the loaded file.

Step 2: The engine finds the 4 symbols

After dlopen(), the engine uses dlsym() to find the 4 named sections:

// This is the actual code:
Resolve("kDartVmSnapshotData")             → memory address 0x7f8a001000
Resolve("kDartVmSnapshotInstructions")     → memory address 0x7f8a010000
Resolve("kDartIsolateSnapshotData")        → memory address 0x7f8a100000
Resolve("kDartIsolateSnapshotInstructions") → memory address 0x7f8a300000

dlsym() returns memory addresses — pointers to where each section was loaded in memory. These addresses point to raw bytes: machine code and data.

📚

Analogy: If dlopen() is opening a book, dlsym() is looking up a chapter by name in the table of contents. "kDartIsolateSnapshotInstructions" → "starts on page 300" (memory address 0x7f8a300000).

Step 3: The Dart VM uses these memory addresses directly

The Dart VM initialization looks roughly like this:

Dart_InitializeParams params;
params.vm_snapshot_data         = memory_address_of_kDartVmSnapshotData;
params.vm_snapshot_instructions = memory_address_of_kDartVmSnapshotInstructions;
Dart_Initialize(¶ms);

// Later, when creating your app's isolate:
Dart_CreateIsolateGroup(
    isolate_snapshot_data,          // address of kDartIsolateSnapshotData
    isolate_snapshot_instructions,  // address of kDartIsolateSnapshotInstructions
);

The Dart VM doesn't "read" these files like a text file. It directly executes the machine code in memory. The kDartIsolateSnapshotInstructions bytes ARE CPU instructions — the VM sets the CPU's instruction pointer to that memory address, and the CPU starts executing your compiled Dart code.

This is the fundamental insight
Your compiled Dart code IS machine code. It runs at the same speed as C++ or Rust code. The Dart VM doesn't interpret it — it directly jumps to the memory addresses where your compiled functions live. The .so file is literally a block of executable instructions + data that the CPU runs natively.

Chapter 3: The Swap — Why Shorebird Works

Now you understand: the .so file is loaded into memory by dlopen(path), symbols are found by dlsym(name), and the Dart VM executes the machine code at those memory addresses.

Shorebird works by changing one thing — the path argument to dlopen().

Without Shorebird (normal Flutter):

application_library_path = ["/data/app/com.app/lib/arm64/libapp.so"]
                                     │
                                     ▼ dlopen()
                              Loads ORIGINAL .so
                                     │
                                     ▼ dlsym("kDartIsolateSnapshotInstructions")
                              Points to ORIGINAL machine code
                                     │
                                     ▼ CPU executes
                              Your ORIGINAL Dart code runs
                              (the version from the Play Store)

With Shorebird (patched):

// shorebird.cc does this BEFORE the Dart VM starts:
application_library_path.clear();
application_library_path = ["/data/.../shorebird_updater/patches/3/dlc.vmcode"]
                                     │
                                     ▼ dlopen()
                              Loads PATCHED .so (dlc.vmcode)
                                     │
                                     ▼ dlsym("kDartIsolateSnapshotInstructions")
                              Points to PATCHED machine code
                                     │
                                     ▼ CPU executes
                              Your UPDATED Dart code runs
                              (with the new button, fixed bug, etc.)

That's the entire trick. The engine, the Dart VM, and the OS don't know or care that the file path changed. dlopen() loads whatever file is at whatever path you give it. The file has the same structure (ELF with 4 symbols), the same symbol names, and valid machine code inside. The CPU executes it exactly the same way.

🎵

Analogy: Imagine a music player that reads a file called song.mp3 from a folder. Normally, song.mp3 is "Yesterday" by The Beatles. But while the player is still loading (before it hits play), you swap the file with "Bohemian Rhapsody" by Queen. The player doesn't notice — it just reads the bytes and plays whatever song those bytes represent. The file format is the same (.mp3), the player doesn't check what the song is, it just plays what's there.

Shorebird does this exact swap — it changes which libapp.so file the Flutter engine loads, before the Dart VM starts playing your app.

Chapter 4: Why dlc.vmcode IS a Valid libapp.so

You might wonder — the patched file is reconstructed from a binary diff on the device. How can a "reconstructed" file work exactly like a "compiled" one?

Because bipatch reconstructs the file byte-for-byte identically. Here's the proof chain:

On the developer's machine:

1. You change Dart code and run shorebird patch

2. The Dart compiler produces a NEW libapp.so — a valid ELF file with all 4 symbols, containing your updated machine code

3. The CLI computes SHA256(new_libapp.so) → say it's "abc123..."

4. The CLI runs bidiff(old, new) → produces a compressed diff

5. The diff + the hash "abc123..." are uploaded to your server

On the user's phone:

1. The Rust updater downloads the compressed diff (150 KB)

2. It decompresses with zstd → gets the raw diff instructions

3. It applies bipatch(original_bundled_libapp.so + diff) → produces a file

4. It computes SHA256(produced_file) → say it's "abc123..."

5. It compares: "abc123..." == "abc123..."MATCH!

The SHA256 hash is a cryptographic guarantee. If even one single bit is different between the file on the developer's machine and the file reconstructed on the phone, the hashes would be completely different (SHA256 is designed this way — any change produces a totally different hash).

Since the hashes match, the file on the phone is bit-for-bit identical to the file the Dart compiler produced on the developer's machine. It IS a valid libapp.so. It contains the exact same ELF headers, the exact same symbol tables, the exact same machine code bytes. dlopen() loads it, dlsym() finds the symbols, the CPU executes the instructions. There's no difference.

dlc.vmcode IS libapp.so
The file extension is different (.vmcode vs .so) but the file contents are identical in structure. The OS doesn't care about the file extension — dlopen() reads the file contents, not the filename. You could name it potato.xyz and it would still load correctly as long as the bytes inside are a valid ELF shared library.

Chapter 5: What Happens to the Original?

An important detail: the original libapp.so bundled in the APK is never modified, never deleted, and never touched.

/data/app/com.example.app-XXXX/lib/arm64/libapp.so    ← ORIGINAL (read-only, inside APK)
                                                          Never changes. Always there.
                                                          Used as BASE for bipatch.

/data/data/com.example.app/files/shorebird_updater/
└── patches/3/dlc.vmcode                               ← PATCHED (read-write, created by updater)
                                                          Contains the new version.
                                                          What actually gets loaded.

The original stays for three reasons:

1. It's the base for bipatch. The binary diff says "copy bytes 0-50000 from the original." Without the original, the diff is useless. Every future patch is diffed against this same original.

2. It's the fallback. If every patch is bad (crash, hash mismatch, server rollback), the updater returns NULL from next_boot_patch_path(). The engine's default application_library_path still points to the original. The app boots with the original code. You can never be "stuck" — the unpatched app always works.

3. It's read-only. The APK is stored in a read-only partition. Android doesn't let apps modify their own APK. Shorebird works entirely within the app's writable data directory — it never touches the APK itself.

Chapter 6: The Engine Doesn't Know

This is the elegant part. The Flutter engine has no idea patching happened.

Look at what the engine sees:

// Engine's perspective:

// It has a list called application_library_path
// It contains one file path
// It calls dlopen() on that path
// It calls dlsym() to find 4 symbols
// It passes those memory addresses to the Dart VM
// The Dart VM runs

// The engine doesn't check:
//   ❌ Where the file came from
//   ❌ When the file was created
//   ❌ Whether the file was compiled or reconstructed
//   ❌ Whether the file matches the APK
//   ❌ Whether the bytes are "original" or "patched"

// The engine only checks:
//   ✅ Does the file exist at the path?
//   ✅ Does dlopen() succeed? (valid ELF format)
//   ✅ Does dlsym() find the 4 symbols?
//   ✅ Are the memory addresses valid?

Shorebird doesn't hack the engine, doesn't intercept any calls, doesn't modify any engine behavior. It simply changes one string (the file path) in a settings object before the engine reads it. The engine's normal code path handles everything else.

🏨

Analogy: Imagine a hotel concierge who gives directions to a restaurant. Normally they say "Go to Room 101 for dinner." Shorebird whispers to the concierge "Actually, say Room 205 tonight." The guest goes to Room 205, finds a beautifully set table with food, and has dinner. They don't know the room changed. The concierge doesn't know why the room changed. The hotel didn't renovate. Someone just moved the dinner to a different room, and told the concierge the new room number.

Chapter 7: What CAN and CAN'T Be Patched

Now that you understand the science, the limitations make sense:

✅ CAN be patched (changes to libapp.so)

Anything that exists as Dart code and compiles into libapp.so:

UI changes: Adding/removing/modifying widgets. A new button, a different color, a redesigned screen — all of this is Dart code that compiles to machine instructions in kDartIsolateSnapshotInstructions.

Business logic: Changing calculations, adding validation, modifying API calls — all Dart functions that compile to machine code.

String changes: Changing text, error messages, labels — these are constants stored in kDartIsolateSnapshotData.

Bug fixes: Fixing null pointer exceptions, off-by-one errors, wrong conditions — the fix changes the machine code instructions for that function.

New Dart packages: Adding a new pub.dev package (that's pure Dart) — its code compiles into libapp.so.

❌ CANNOT be patched (changes outside libapp.so)

Anything that lives in the APK but NOT in libapp.so:

Native code (Kotlin/Java/Swift/ObjC): These compile to separate .so or framework files, not into libapp.so. Shorebird doesn't touch those.

AndroidManifest.xml / Info.plist: Permissions, activities, app metadata — these are APK/IPA level, not Dart.

Assets (images, fonts, JSON files): These are bundled separately in the APK's assets folder, not compiled into libapp.so.

Native plugin code: If a Flutter plugin has native Kotlin/Swift code, changing the plugin version might change that native code. libapp.so only contains the Dart side of plugins.

Flutter engine itself: libflutter.so is a separate binary. Upgrading the Flutter engine version requires a new APK.

The simple rule
If you only changed .dart files → it can be patched.
If you changed anything else (native code, assets, manifest, plugins with native code, Flutter version) → you need a new release through the app store.

Chapter 8: Putting It All Together — The Full Picture

YOU (Developer)                    YOUR SERVER                  USER'S PHONE
─────────────                      ────────────                 ────────────

1. Write Dart code
   flutter build → gen_snapshot
   compiles to libapp.so
   (AOT machine code)
        │
        ▼
2. shorebird release
   Upload original libapp.so ──────►  Stored on server
                                      (the "base")
        │
        │   Users download your app
        │   from Play Store ───────────────────────►  APK installed
        │                                             Contains:
        │                                             • libflutter.so (engine+updater)
        │                                             • libapp.so (original Dart code)
        │                                             • shorebird.yaml (config)
        │
3. Change Dart code (add a button)
   Compile new libapp.so
   Download original from server
   bidiff(original, new) → diff
   zstd compress → tiny patch
        │
        ▼
4. shorebird patch
   Upload patch + SHA256 hash ─────►  Stored on server
                                      (the "patch")
        │
        │   User opens app
        │                    ◄─── shorebird.cc runs:
        │                          1. Init updater (Rust)
        │                          2. Check: any installed patch? → YES
        │                          3. Swap path to dlc.vmcode
        │                          4. Dart VM loads PATCHED code
        │                          5. User sees new button! ✓
        │
        │   Background thread:  ◄─── Also happening:
        │                          6. POST /patches/check
        │                    ◄──── 7. "No newer patch" → do nothing
        │                             (or download next patch for next launch)


═══════════════════════════════════════════════════════════════════════

The "magic" is entirely in step 3:

  shorebird.cc swaps the file path BEFORE the Dart VM starts.
  
  The Dart VM loads the file.
  dlopen() loads whatever file is at that path.
  The file is a valid libapp.so (verified by SHA256).
  The CPU executes the machine code inside.
  Your new button renders on screen.

There is no magic. Just a file path swap + verified binary reconstruction.
Summary — The Science in One Paragraph
Your Dart code compiles to native machine code stored in libapp.so. This file is loaded by dlopen() into the process's memory space, and the CPU directly executes the instructions inside. Shorebird creates a binary diff between the old and new libapp.so, compresses it, and sends the tiny diff to phones. On the phone, the Rust updater reconstructs the exact new libapp.so from the diff + the original (verified by SHA256 hash). Before the Dart VM starts, shorebird.cc changes the file path from the original to the reconstructed file. dlopen() loads the reconstructed file — which is byte-for-byte identical to what the compiler produced — and the CPU executes the updated machine code. The new button appears because its compiled ARM64 instructions are now in the memory that the CPU is executing from. Nothing was "injected" or "hacked" — the engine simply loaded a different (but structurally identical) file.

Post a Comment

Previous Post Next Post

Subscribe Us


Get tutorials, Flutter news and other exclusive content delivered to your inbox. Join 1000+ growth-oriented Flutter developers subscribed to the newsletter

100% value, 0% spam. Unsubscribe anytime