In Rust, panic! is a deliberate crash. The program prints a stack trace and terminates. In test code, panics are the standard mechanism for assertions -- every assert!, assert_eq!, and unwrap() is a potential panic, and that is by design. In production code, panics are almost always bugs. They turn what should be a handled error into a process crash, and in a language runtime like FLIN, a crash means every application running on that runtime goes down.
The FLIN audit catalogued every panic call in 186,252 lines of Rust code. The raw number was alarming: over 800 panic sites across the codebase. The refined number was reassuring: only 5 were in production code paths. The remaining were test assertions, which is exactly where panics belong.
This article traces those five production panics -- where they live, why they exist, whether they should be eliminated, and what the elimination strategy looks like.
The Panic Census
The audit counted panic calls at three levels: explicit panic!() macro invocations, .unwrap() calls on Result and Option types, and .expect() calls that provide a message before panicking. Together, these form the complete set of panic sites in the codebase.
Module Explicit panic! .unwrap() .expect() Total
codegen/bytecode.rs 2 0 0 2
vm/vm.rs 48 0 0 48
vm/renderer.rs 9 0 0 9
parser/parser.rs ~600 0 0 ~600
tests/** 0 ~120 ~20 ~140
-----
TOTAL ~659 ~120 ~20 ~799The parser's 600 panics were all in test code (line 8866 onward). The tests directory's 140 panics were by definition test code. That left the codegen, VM, and renderer modules with 59 production panic sites to evaluate.
The Five Production Panics
After filtering out panics that were only reachable through internal code paths (never triggered by user-written FLIN code), the audit identified five panics that a FLIN developer could theoretically trigger:
PANIC-001: Constant Pool Overflow
rust// codegen/bytecode.rs line 1711
pub fn add_constant(&mut self, value: Value) -> u16 {
if self.constants.len() >= u16::MAX as usize {
panic!("Constant pool overflow: too many constants (max {})", u16::MAX);
}
self.constants.push(value);
(self.constants.len() - 1) as u16
}Can a user trigger this? Yes, but only with an extraordinarily large FLIN program -- one that defines more than 65,535 unique constants (string literals, numbers, identifiers). A typical FLIN application uses a few hundred constants. A very large application might use a few thousand. Reaching 65,535 would require deliberate effort.
Should this be a panic? Arguably yes. This is a hard limit of the bytecode format -- constant pool indices are stored as u16 values. Exceeding this limit means the bytecode cannot represent the program. The appropriate response is to refuse compilation, which a panic effectively does (though a proper error message would be better).
Verdict: Convert from panic! to a CompileError::ConstantPoolOverflow that produces a friendly error message telling the developer to split their program into modules.
PANIC-002: Float Type Assertion
rust// codegen/bytecode.rs line 2709
fn read_float_constant(&self, index: u16) -> f64 {
match &self.constants[index as usize] {
Value::Float(f) => *f,
other => panic!("Expected Float constant at index {}, got {:?}", index, other),
}
}Can a user trigger this? No, under normal circumstances. This function is only called when the compiler has already determined that the constant at the given index is a float. If the assertion fails, it means the compiler has a bug -- it stored one type but claimed another.
Should this be a panic? This is a reasonable internal consistency check. If this panic fires, the compiler is in an inconsistent state and continuing execution would produce undefined behavior. However, converting it to a Result would allow the error to propagate through the normal error handling chain instead of crashing the process.
Verdict: Convert to Result for cleaner error reporting, but mark as low priority since the condition should be unreachable.
PANIC-003 through PANIC-050: VM Type Assertions
The VM contains 48 panic calls, almost all following the same pattern:
rust// vm/vm.rs -- typical type assertion pattern
fn builtin_string_upper(&mut self) -> Result<Value, VmError> {
let value = self.pop()?;
let s = match value {
Value::Object(id) => self.get_string(id)?.to_uppercase(),
Value::Text(s) => s.to_uppercase(),
_ => panic!("upper() requires a string argument, got {:?}", value),
};
Ok(Value::text(s))
}Can a user trigger these? Yes. If a FLIN developer writes upper(42), the VM will receive an integer value where it expects a string, and the panic will fire. The typechecker should catch this at compile time, but FLIN's typechecker does not yet cover all calling contexts, and dynamic values (from database queries, HTTP parameters, or user input) bypass static type checking entirely.
Should these be panics? Absolutely not. These are the most important panics to eliminate. A FLIN developer passing the wrong type to a built-in function should receive a clear runtime error, not a process crash. The error should name the function, the expected type, the received type, and ideally the source location.
Verdict: Convert all 48 to VmError::TypeError returns. This is the highest-priority panic elimination task.
rust// BEFORE: crash on type mismatch
fn builtin_string_upper(&mut self) -> Result<Value, VmError> {
let value = self.pop()?;
let s = match value {
Value::Object(id) => self.get_string(id)?.to_uppercase(),
Value::Text(s) => s.to_uppercase(),
_ => panic!("upper() requires a string argument"),
};
Ok(Value::text(s))
}
// AFTER: proper error on type mismatch
fn builtin_string_upper(&mut self) -> Result<Value, VmError> {
let value = self.pop()?;
let s = self.as_string(&value)
.map_err(|_| VmError::TypeError {
function: "upper",
expected: "text",
got: value.type_name(),
})?;
Ok(Value::text(s.to_uppercase()))
}PANIC-051 through PANIC-059: Renderer Assertions
The renderer contains 9 panic calls, primarily in expression evaluation and HTML generation:
rust// vm/renderer.rs -- typical renderer panic
fn extract_text_content(&self, value: &Value) -> String {
match value {
Value::Text(s) => s.clone(),
Value::Object(id) => {
self.vm.get_string(*id)
.unwrap_or_else(|_| panic!("Failed to extract text from {:?}", value))
}
_ => panic!("Expected text value, got {:?}", value),
}
}Can a user trigger these? Yes. Template expressions that evaluate to unexpected types can reach these panic sites. For example, {someVariable} where someVariable is a map or a list rather than a string.
Should these be panics? No. Template rendering should be robust against unexpected value types. The renderer should either convert the value to a string representation (using a Display implementation) or produce a visible error in the HTML output without crashing the server.
Verdict: Convert to graceful degradation -- render a visible error marker in the HTML output and log a warning, but never crash the server process.
The Elimination Strategy
The audit proposed a three-tier strategy for panic elimination:
Tier 1: Convert to VmError (48 VM panics). Every type assertion in the VM's native function handlers should return Result<Value, VmError> instead of panicking. This is mechanical work -- each conversion follows the same pattern -- but it touches 48 call sites across a 27,257-line file, so it requires careful verification.
Tier 2: Convert to graceful degradation (9 renderer panics). Renderer panics should be replaced with fallback rendering that produces visible but non-crashing output. A template expression that fails to evaluate should render as [Error: expected text, got map] in development mode and as an empty string in production mode.
rust// Tier 2: graceful degradation in renderer
fn extract_text_content(&self, value: &Value) -> String {
match value {
Value::Text(s) => s.clone(),
Value::Object(id) => {
self.vm.get_string(*id)
.unwrap_or_else(|_| format!("[render error: invalid object {}]", id))
}
Value::Int(n) => n.to_string(),
Value::Float(f) => f.to_string(),
Value::Bool(b) => b.to_string(),
Value::None => String::new(),
other => format!("[render error: unexpected {:?}]", other.type_name()),
}
}Tier 3: Convert to CompileError (2 codegen panics). The constant pool overflow and float type assertion should produce compiler errors rather than runtime crashes. These are the lowest priority because they are the hardest to trigger.
The 120 Test Panics
The 120 panics in test code are not bugs. They are the standard Rust testing mechanism:
rust#[test]
fn test_parser_handles_entity_declaration() {
let source = "entity User { name: text }";
let ast = parse(source).unwrap(); // Panics if parsing fails
match &ast.statements[0] {
Stmt::EntityDecl { name, .. } => {
assert_eq!(name, "User"); // Panics if name is wrong
}
other => panic!("Expected EntityDecl, got {:?}", other),
}
}Every .unwrap() is a panic site. Every assert_eq! is a panic site. Every explicit panic!() in a test is an assertion that the test condition was not met. These are correct uses of panic -- they terminate the test with a failure message, which is exactly what test assertions should do.
The audit verified that all 120 test panics were in test modules (#[cfg(test)]) or test files (tests/*.rs) and would never execute in production builds.
Measuring Progress
The panic elimination effort can be tracked quantitatively:
Production panic sites:
Start of audit: 59 (48 VM + 9 renderer + 2 codegen)
After Tier 1: 11 (0 VM + 9 renderer + 2 codegen)
After Tier 2: 2 (0 VM + 0 renderer + 2 codegen)
After Tier 3: 0 (0 VM + 0 renderer + 0 codegen)
Target: zero production panics before beta releaseEach tier can be verified independently -- after Tier 1 is complete, running grep -r "panic!" src/vm/vm.rs | grep -v "#\[cfg(test)\]" should return zero results. After Tier 2, the same for renderer.rs. After Tier 3, the same for codegen/bytecode.rs.
Why Zero Panics Matters
A language runtime is infrastructure. When a FLIN developer writes a web application and deploys it to production, they are trusting that the runtime will not crash under any input their users provide. A panic in the runtime is not just a bug in FLIN -- it is downtime for every application built on FLIN.
The five production panics identified by the audit were not dangerous in practice -- they required specific, unusual inputs to trigger. But "unusual" today becomes "common" when thousands of developers are writing FLIN code. Edge cases stop being edges when the population of code is large enough.
Zero production panics is not a theoretical goal. It is a concrete, measurable property of the binary. Either the panic! calls exist in production code paths, or they do not. The audit gave us the exact list. The fix sessions would eliminate them. And the CI pipeline would ensure they never return.
This is Part 154 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: - [153] What the Audit Taught Us About Building a Language - [154] Production Panic Calls: Tracking and Elimination (you are here) - [155] 93 Sessions Audited in One Pass