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.)
| Implementation | Intermediate file | First run | When hot | Does my code become machine code? |
|---|---|---|---|---|
| Python (CPython) | bytecode .pyc | interpreter | still the interpreter | no |
| Java (HotSpot) | bytecode .class | interpreter | JIT machine code | only the hot parts |
| JavaScript (V8) | none | interpreter | JIT machine code | only the hot parts |
| Dart (VM) | AST .dill | unoptimized JIT machine code | optimized JIT machine code | all of it, at run time |
| Dart (AOT), C, Rust, Go | machine code | machine code | machine code | all 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 runis JIT,dart compile exeis 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.
| Name | What is saved | What’s inside | Runs on |
|---|---|---|---|
| Kernel snapshot | a dill file | AST, no machine code | VM (JIT) |
| App-JIT snapshot | the VM heap | loaded classes plus JIT machine code for every function called during a training run | VM (JIT) |
| AOT snapshot | the VM heap | loaded classes plus machine code for every function, compiled ahead of time | dartaotruntime (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.