Your Nimony release build swallowed the error
A Nimony routine raised an error. The debug build caught it; the release build returned 42 as if nothing had happened. Test error paths in the build mode you ship. Checking one AI-agent skill against the compiler found this bug, six more error-handling defects and three rules the compiler had already outgrown. All seven bugs were fixed upstream the next day.
Release returns 42, debug raises
$ nimony c -r repro.nim
caught IOError
$ nimony c -d:release -r repro.nim
outer(-1) returned 42
Same source, same compiler. Only -d:release differs.
import std/syncio
proc leaf(x: int) {.raises, noinline.} =
if x < 0: raise IOError
proc relay(x: int) {.raises.} = leaf(x) # small: gets spliced
proc outer(x: int): int {.raises, noinline.} =
relay(x)
result = 42
try:
let r = outer(-1)
echo "outer(-1) returned ", r
except ErrorCode as e:
echo "caught ", e
leaf(-1) raises, relay and outer forward it, and the try should catch it. In a release build, outer returns 42, the except arm never runs, nothing reaches stderr and the exit code is 0.
It turned up while I was measuring a real JSON-Lines workload: a malformed record was accepted in the release build and never reported. Shipped, the first sign of trouble would have been bad data downstream.
Why debug tests never see it
relay is tiny, so the optimizer splices its body into outer, and after the splice the check for a pending error looked at the wrong place. Debug builds do not inline relay, so every debug test passes. The upstream fix is in the inliner (nim-lang/nimony#2682).1
An agent cannot reason its way to this. The source and the semantics on paper are correct. Only running the program under the flags you ship shows the problem.
Three outdated rules, seven compiler bugs
The skill I was checking had been written quickly from documentation and experience. It stated three rules as fact, and on Nimony 0.6.3 at commit 5574918d the compiler contradicted all three:
| The skill said | The compiler did |
|---|---|
Only ErrorCode can be raised. | {.raises: T.} also accepted a domain enum (ordinals 1–255) and ref object of Exception types. |
| Exceptions never carry messages. | A ref object of Exception keeps msg and its own fields, at the cost of one allocation per raise. |
Keep errors out of Result. | std/result works. Don’t wrap every internal helper in Result, but one Result per unit of work at a public entry point is fine. |
An agent following the old rules avoids features that work. An agent that finds those features unguided walks into the defects below, because they all sat on exactly those paths. I wrote a small program for each corrected claim and ran it in debug and release builds. Seven exposed compiler defects, filed on 15 October 2026 and all closed as completed the next day:
- #2674: optimized builds silently drop an error raised through a spliced relay routine (the program above).
- #2675: optimized builds fail C compilation when
except ErrorCode as efollows a statement-position raising call. - #2676:
{.raises: Enum.}truncates ordinals above 255 to 0, so the error becomes “no error”. - #2677: a plain
objectas the raises type passes semantic checking but produces invalid C. - #2678: typed ref
exceptarms without a catch-all, in a non-raising routine, produce invalid C or silently swallow the exception. - #2679: an
ErrorCodepropagating into a ref-exception handler segfaults. - #2680:
except T as edoes not filter value-code error types, so aBarm catches anA.
Four of the seven lose an error silently, catch the wrong one, or crash. None shows up in a debug-only test of the happy path.
Then the compiler moved again
The fix for #2675–#2680 (nim-lang/nimony#2695, merged on 16 October) also changed the language rule: .raises now takes ErrorCode or a ref object type, and nothing else. A domain enum accepted the day before is now rejected:
.raises takes ErrorCode or a ref object type, not Codeenumraise.nim(3, 34)type Code = enum cNone, cBad
proc leaf(x: int): int {.raises: Code.} =
^
Nimony 0.6.3 @ 0a20752c, which includes the fixes. The same file compiled on 5574918d.
So my correction (“domain enums work too”) was out of date one day after I made it, and the version string still said 0.6.3. Re-running the suite on the new build failed exactly the tests that pinned the old behavior, which marked the sentences in the skill that had to change. An agent’s knowledge of a compiler that changes weekly is a snapshot of an unknown date. Re-verification is what keeps the guidance current, and it is what the subscription pays for.
What the skill says now
expected a digit at 1 on 5574918d and 0a20752ctype
ParseError = ref object of Exception
pos: int
proc parseDigit(s: string; i: int): int {.raises: ParseError.} =
if s[i] notin {'0'..'9'}:
raise ParseError(msg: "expected a digit", pos: i)
result = ord(s[i]) - ord('0')
try:
discard parseDigit("4x", 1)
except ParseError as e:
echo e.msg, " at ", e.pos
Use ErrorCode for cheap, message-free errors and a ref exception type when the handler needs context, and test error paths in the build mode you ship, not only in debug. Every Nim and Nimony skill in the catalog is checked against the compiler this way; the ownership-hooks case study shows what that catches in Nim 2.
Run on Linux amd64 with the C backend and the default memory manager. Nimony is pre-1.0; treat any rule here as true for the commits named.
Notes
- I reproduced #2674 by building Nimony at commit
5574918dfrom source and running the program above with and without-d:release. On a build at0a20752c, which contains the upstream fix commitsc3c6013d(#2682) and159960c3(#2695), both builds printcaught IOError. ↩