The worst bugs are not the ones that crash your program. The worst bugs are the ones that silently produce wrong results. A crash is honest -- it tells you something is broken and where. A silent data corruption hides behind correct-looking output, waiting for exactly the right conditions to reveal itself. And when those conditions finally arrive, the symptoms point everywhere except the actual cause.
FLIN had such a bug. Two separate implementations of the same opcode, living in the same file, 3,418 lines apart, producing subtly different results depending on which execution path the VM took. The CreateMap opcode -- responsible for constructing every hash map in every FLIN program -- existed in two forms. One handled Value::Text keys. The other did not. And for weeks, translation maps were silently dropping entries.
The Discovery
The bug surfaced during pre-audit debugging when translation lookups started returning raw keys instead of translated values. A FLIN application that should have displayed "Mes taches" was instead showing tasks.title. The translation map was being constructed, the keys were being inserted, but when the VM later tried to look up those keys, the values were gone.
The first hypothesis was a garbage collection issue -- perhaps the string objects were being collected prematurely. The second hypothesis was a scope problem -- maybe the translation map was being constructed in a scope that got discarded. Both hypotheses were wrong.
The actual cause was simpler and more insidious. The FLIN VM has two separate match blocks for opcode dispatch:
// vm.rs line 1127 -- inside execute_until_return()
// Used for user-defined function calls (like t() in templates)
match opcode {
OpCode::CreateMap => {
let count = self.read_u16(code) as usize;
let mut map = HashMap::new();
for _ in 0..count {
let value = self.pop()?;
let key = self.pop()?;
if let Value::Object(key_id) = key {
if let Ok(obj) = self.get_object(key_id) {
if let ObjectData::String(s) = &obj.data {
map.insert(s.clone(), value);
}
}
} else if let Value::Text(s) = key {
map.insert(s, value); // <-- HANDLES Value::Text
}
}
let id = self.alloc_map(map);
self.push(Value::Object(id))?;
}
// ... 170+ other opcodes
}// vm.rs line 4796 -- inside run() // Used for top-level code execution match opcode { OpCode::CreateMap => { let count = self.read_u16(code) as usize; let mut map = HashMap::new(); for _ in 0..count { let value = self.pop()?; let key = self.pop()?; if let Value::Object(id) = key { if let Ok(s) = self.get_string(id) { map.insert(s.to_string(), value); } } // <-- DOES NOT handle Value::Text } let id = self.alloc_map(map); self.push(Value::Object(id))?; } // ... 170+ other opcodes } ```
The first implementation at line 1378, inside execute_until_return, correctly handled both Value::Object (heap-allocated strings) and Value::Text (inline strings). The second implementation at line 4796, inside run, only handled Value::Object. When a map key happened to be a Value::Text -- which the compiler could emit for short, compile-time-known strings -- the run version silently skipped the insertion.
Why Two Match Blocks Exist
To understand how this happened, you need to understand FLIN's execution model. The VM has two primary execution functions:
run()-- executes the top-level bytecode of a FLIN file. This is the entry point when a page loads or a server starts.execute_until_return()-- executes a function call. Whenrun()encounters anOpCode::Call, it invokesexecute_until_return()with the called function's bytecode.
Both functions need to handle the same opcodes, because the same operations (creating maps, performing arithmetic, accessing variables) can appear in both top-level code and inside functions. Ideally, they would share a single opcode dispatch table. In practice, the two functions evolved independently across 301 sessions, with different developers (or the same AI developer in different context windows) implementing opcodes in one function but not the other.
// The fundamental architectural issue: two dispatch loops
// that must stay synchronized but have no mechanism to enforce itpub fn run(&mut self, code: &[u8]) -> Result
pub fn execute_until_return(&mut self, code: &[u8]) -> Result
By the time of the audit, execute_until_return handled 59 opcodes (about 35% of the full opcode set). The run function handled more, but with subtle differences in implementation details. The CreateMap divergence was the most critical because it affected data correctness, but it was not the only gap.
The Impact
Translation maps in FLIN are regular hash maps constructed with the CreateMap opcode. When the translation assignment runs at the top level of a FLIN file:
translations = {
en: {
"tasks.title": "My Tasks",
"tasks.add": "Add Task"
},
fr: {
"tasks.title": "Mes taches",
"tasks.add": "Ajouter une tache"
}
}This code compiles to a series of CreateMap opcodes executed by run(). If any of the keys are emitted as Value::Text (the compiler's optimization for small strings), those key-value pairs are silently dropped. The map appears to be constructed successfully -- no error, no warning -- but it is missing entries.
When the template later calls t("tasks.title"), the translation function executes inside execute_until_return(), which has the correct CreateMap implementation. But it is too late -- the translation map it is looking up from was already constructed with missing entries.
The observable symptom was that translations worked intermittently. Some keys would resolve, others would not. The pattern depended on which keys the compiler chose to emit as Value::Text versus Value::Object, which itself depended on string length and compiler optimization decisions that changed between sessions.
The Fix
The fix was surgical -- one else if branch added to the run() version of CreateMap:
// vm.rs line ~4807 -- the fix
OpCode::CreateMap => {
let count = self.read_u16(code) as usize;
let mut map = HashMap::new();
for _ in 0..count {
let value = self.pop()?;
let key = self.pop()?;
if let Value::Object(id) = key {
if let Ok(s) = self.get_string(id) {
map.insert(s.to_string(), value);
}
} else if let Value::Text(s) = key {
map.insert(s, value); // <-- THE FIX: handle Value::Text
}
}
let id = self.alloc_map(map);
self.push(Value::Object(id))?;
}One line of Rust. Three tokens: else if let. That was the difference between translation maps that worked and translation maps that silently dropped entries.
The fix was applied in Session 260 (January 29, 2026) and immediately verified:
Verification Results:
- cargo test: 3,108 tests passed
- Translation maps: all keys resolve correctly
- Todo apps verified: 9 of 10 passingThe tenth todo app's failure was unrelated -- a component click handler serialization issue that would be fixed in the next session as FIX-006.
The Deeper Problem: Opcode Coverage
The duplicate CreateMap was the most visible symptom, but the underlying disease was far worse. Session 273 would later conduct an exhaustive opcode coverage audit of execute_until_return and discover that it only handled 35% of all opcodes -- 59 out of 170+. Any opcode not explicitly handled fell through to a continue statement, silently doing nothing.
This meant that any function called via _flinAction (the mechanism for button click handlers in FLIN's template system) could silently skip operations. For loops, entity queries, closure operations, list manipulations, bitwise operations -- all of these were effectively no-ops when executed inside a function call triggered by a user interaction.
// Before Session 273: the silent failure
match opcode {
OpCode::Add => { /* handled */ }
OpCode::Sub => { /* handled */ }
// ... 57 more handlers
_ => continue, // EVERYTHING ELSE: silently skip
}// After Session 273: explicit handling for 130+ opcodes match opcode { OpCode::Add => { / handled / } OpCode::Sub => { / handled / } OpCode::StartFor => { / NOW handled / } OpCode::QueryAll => { / NOW handled / } OpCode::GetUpvalue => { / NOW handled / } OpCode::ListPush => { / NOW handled / } // ... 130+ total handlers _ => { return Err(VmError::UnhandledOpcode(opcode)); } } ```
Session 273 added 70+ opcode handlers to execute_until_return, bringing coverage from 35% to 75%. The remaining 25% were opcodes that legitimately should never appear inside function calls (like module-level declarations).
Lessons for Language Implementors
The CreateMap bug teaches three lessons that apply to any language implementation:
First, never duplicate dispatch tables. If two functions need to handle the same set of opcodes, they should share a single dispatch mechanism -- either through a common function, a trait, or a macro-generated match block. Duplication inevitably leads to divergence.
Second, silent fallthrough is always wrong for opcode dispatch. The _ => continue pattern in execute_until_return was the root cause of dozens of silent failures. A language VM should either handle an opcode or explicitly error on it. There is no safe middle ground.
Third, the compiler's choice of value representation is an implementation detail that must never affect semantics. Whether the compiler emits a string as Value::Text or Value::Object should produce identical behavior in every opcode handler. If it does not, you have a correctness bug that will manifest unpredictably.
The duplicate CreateMap opcode was fixed in one line. The lesson it taught us -- that our VM had a structural vulnerability to implementation divergence -- drove weeks of follow-up work that fundamentally strengthened FLIN's execution engine.
---
This is Part 147 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [146] Auditing 186,000 Lines of Code - [147] The Duplicate Opcode That Almost Broke Everything (you are here) - [148] 30 TODOs, 5 Production Panics, 0 Security Issues