The Symptom
The FLIN todo app's addTask() function stopped creating entities. No error messages. No crashes. The server returned {"type":"ok"} -- but nothing was persisted. Meanwhile, saveEdit() and deleteTask() worked perfectly.
Silent failure is the worst kind of bug.
The Investigation
The VM has two opcode dispatch paths:
1. run() -- Normal execution loop
2. execute_until_return() -- Used for function calls triggered by actions (button clicks, form submissions)
When a user clicks "Add Task," the action handler calls addTask() via execute_until_return(). This function creates an entity (CreateEntity opcode), sets fields (SetField opcode), and saves (Save opcode).
SetField was in both dispatch tables. CreateEntity was only in run().
What Happened at Runtime
1. User clicks "Add Task"
2. Action handler calls execute_until_return()
3. Bytecode reaches CreateEntity opcode
4. Opcode not found in execute_until_return() dispatch
5. Falls through to default case
6. Instruction pointer advances incorrectly
7. Next opcodes interpreted as garbage
8. Function returns early
9. Save opcode never reached
10. Server returns "ok" (no error was thrown)The function appeared to succeed because no error was raised. The entity was never created because the creation opcode was never executed.
The Fix
150 lines of CreateEntity handling copied to execute_until_return(). The exact same logic, in the second dispatch table.
The Prevention Checklist
We created a checklist for every new opcode addition:
- Does this opcode need to work in
execute_until_return()? - Check: entity operations (CreateEntity, SetField, Delete, Save)
- Check: variable operations (SetGlobal, GetGlobal, SetLocal, GetLocal)
- Check: data structure operations (CreateMap, CreateList, Index)
- Check: arithmetic and logic operations
Any opcode that could execute inside a function called by an action handler must exist in both dispatch tables.
The Lesson
Dual dispatch paths are maintenance hazards. Every opcode addition is a potential silent failure if added to one path but not the other. The solution is either: unify the dispatch (one table), or automate the synchronization (generate both from a single definition).
Silent failures are worse than crashes. A crash tells you something is wrong. Silent failure tells you everything is fine while your users' data disappears.