Post

Dart Kernel and Snapshots

Dart Kernel and Snapshots

Intro

A write-up of how a dart run native assets race issue was tracked down and fixed.

Kernel

The Dart VM doesn’t read Dart source directly. A compiler front end called the CFE (Common Front End) parses the source, runs type checking, and turns it into an intermediate representation. That representation is Kernel, and a Kernel saved to a file is a .dill.

1
2
source (.dart) → [CFE] → Kernel (.dill) ─→ [VM] JIT execution      (dart run)
                                        └→ [gen_snapshot] AOT       (dart compile exe)

Kernel is the fork in the road. The same dill can be run directly by the VM under JIT, or handed to the AOT compiler as input.

Several tools invoke the CFE. frontend_server, used by pub and Flutter, and kernel_service, which lives inside the VM, are both the same CFE and both produce the same dill.

dill

A dill is the type-checked AST, serialized to binary. It is a structure, not a sequence of instructions like machine code or bytecode. Put the same return x + 1 in both forms and the difference is obvious.

1
2
3
4
5
bytecode (Python .pyc)       dill
LOAD_FAST    x               Return
LOAD_CONST   1               └─ BinaryOp(+)
BINARY_OP    +                  ├─ Variable(x)
RETURN_VALUE                    └─ Literal(1)

The left side runs as is. Walk it top to bottom, one line at a time. The right side says “add”, but it doesn’t say what to fetch first or when to add. Deciding that order is what compilation does, and the VM does it right before execution, turning the tree into machine code.

Interpreted vs compiled

A simple way to draw the line is this. Does my code get translated into real CPU machine code that the CPU runs directly, or does another program, an interpreter, read my code as data and do the work on its behalf? (Bytecode falls in the second group. It’s instructions for a virtual CPU, not a real one.)

ImplementationIntermediate fileFirst runWhen hotDoes my code become machine code?
Python (CPython)bytecode .pycinterpreterstill the interpreterno
Java (HotSpot)bytecode .classinterpreterJIT machine codeonly the hot parts
JavaScript (V8)noneinterpreterJIT machine codeonly the hot parts
Dart (VM)AST .dillunoptimized JIT machine codeoptimized JIT machine codeall of it, at run time
Dart (AOT), C, Rust, Gomachine codemachine codemachine codeall of it, ahead of time

So “interpreted” and “compiled” are properties of an implementation, not of a language. The same language can go either way depending on the engine, VM, or compiler.

  • Python: CPython interprets all the way down, PyPy JITs, Cython converts to C and compiles to machine code ahead of time
  • Java: HotSpot is interpreter plus JIT, GraalVM native-image compiles the whole program to machine code ahead of time
  • JavaScript: V8 JITs, QuickJS interprets
  • Dart: dart run is JIT, dart compile exe is AOT

In Dart’s case, the VM compiles a method the first time it’s called, into quick, unoptimized machine code. If the method gets called often, it’s replaced with an optimized version. There is no interpreter stage. The cost is startup time, and that’s what App-JIT snapshots and AOT are for.

Snapshot

In Dart, a snapshot is the state needed to run a program, saved to a file. Whatever was built in memory is written out as raw bytes, and the next run reads it back instead of building it again.

NameWhat is savedWhat’s insideRuns on
Kernel snapshota dill fileAST, no machine codeVM (JIT)
App-JIT snapshotthe VM heaploaded classes plus JIT machine code for every function called during a training runVM (JIT)
AOT snapshotthe VM heaploaded classes plus machine code for every function, compiled ahead of timedartaotruntime (AOT-only runtime)

The heap is a region of memory, but everything in it is bytes. Class metadata, functions, constants, compiled machine code. All of it can be written to a file. Pointers between objects won’t be valid on the next run, so on write each reference is replaced with an object index, and on read the indexes are turned back into fresh addresses.

The file pub writes to .dart_tool/pub/bin/…snapshot is a Kernel snapshot. The extension says .snapshot, but the content is a dill. Below, this file is referred to both as “the snapshot” and as “the Kernel”.

Two paths through dart run

Who produces the Kernel, and when, depends on whether the argument is a file or a package name.

1
2
3
dart run bin/foo.dart   dartdev → VM (kernel_service compiles on the fly, no dill file left behind)
dart run foo            dartdev → pub → saves a Kernel snapshot → VM runs that file
                                        (.dart_tool/pub/bin/…)

The package name path goes through pub because someone has to turn the name into an actual file path. pub knows which package foo is, where it lives on disk, and what its entry point is. And since packages run by name are usually tools whose source doesn’t change, pub caches the compiled result at a fixed path inside the project while it’s at it.

The native assets mapping goes into the Kernel

The issue started at the moment the VM tried to find a C function that package:webcrypto declares with @Native. A package that ships its native library through a build hook tells the VM which library holds each @Native symbol through a mapping file, native_assets.yaml. That mapping isn’t read at run time. It’s compiled into the Kernel. The compiler reads the yaml, synthesizes a library named vm:ffi:native-assets (no source file, only annotations), and appends it to the end of the dill. The first time a @Native function is called, the VM consults that library’s mapping to resolve the symbol.

1
2
3
4
vm:ffi:native-assets
@pragma('vm:ffi:native-assets', {
  'package:foo/foo.dart': ['absolute', 'C:\...\Temp\dart_native_assets_XXXX\lib\foo.dll']
})

In other words, the absolute path to the DLL is baked into the Kernel as a constant. That’s what became the issue.

How the issue was resolved

The first race was on the DLL file itself. Its path was fixed under .dart_tool/lib/, so every dart run deleted that one file and copied it back. One run would fail deleting a DLL another run had already loaded, or open a DLL that was only half copied.

The first fix put the DLL in a separate temp directory per run and deleted it when that run exited. That made the DLL independent, but the snapshot that holds the DLL path was racing too. That’s the snapshot pub caches. There’s one cache path per entry point script, so every dart run was overwriting and reading the snapshot at the same location.

1
2
3
4
5
6
7
8
dart run A → Kernel_A (contains path temp_A) → .dart_tool/pub/bin/foo/foo.dart-<sdk>.snapshot
dart run B → Kernel_B (contains path temp_B) → .dart_tool/pub/bin/foo/foo.dart-<sdk>.snapshot  (same file)

1. A writes Kernel_A to the file
2. B overwrites it with Kernel_B
3. A runs the file → Kernel_B runs, and looks for its DLL in temp_B, which isn't A's
4. B exits and deletes temp_B
5. A tries to open the DLL in temp_B and fails

The only thing in the snapshot that differs between runs is the mapping with the DLL path. The code is the same. The dill format allows multiple components to be concatenated, and the VM keeps reading the next component when one ends. So the mapping can be split off and appended on its own.

1
2
3
4
5
6
7
source (before)
  source + native_assets.yaml → compiled together → dill (code + mapping)

concatenation (after)
  source → code dill                   same every run, cacheable
  native_assets.yaml → mapping dill    small, fresh every run
  concatenate the two files            → VM reads both

In principle it’s the same as reading both files with readAsBytes and writing [...a, ...b]. Here’s what dartdev actually does.

1
2
3
4
5
1. pub caches a Kernel snapshot with code only at .dart_tool/pub/bin/…
2. frontend_server --native-assets-only --native-assets=<yaml>
       --output-dill=<temp>/native_assets.dill      a dill with only the mapping
3. <temp>/concatenated.dill ← stream of 1 + stream of 2   (addStream)
4. VM runs concatenated.dill, temp directory deleted on exit
1
2
3
4
<temp>/
├─ lib/foo.dll           hook output, copied by dartdev
├─ native_assets.dill    mapping only, output of frontend_server --native-assets-only
└─ concatenated.dill     pub snapshot + native_assets.dill, the file the VM runs

Concatenation was merged into main on 2026-09-09 (CL 542880).

Verified with Dart 3.14.0-248.0.dev on the repro repo (litert_crypto, repro/dll-race branch). The repo runs a Flutter asset transformer over 11 assets, and each asset spawns its own dart run litert_crypto, so 11 dart run processes run at once. All 11 finished cleanly.

This post is licensed under CC BY-NC 4.0 by the author.