Nim =wasMoved: why a moved-from object segfaults on reuse
Nil only the pointer in =wasMoved and the next legal write to the moved-from value goes through nil. The hook must reset every field your type’s operations read, not only the one =destroy checks. Below are three ownership-hook rules that pass review and ordinary tests and are still wrong. One survived into my own first verified skill.
The segfault
var b = move(a)
a.writeByte 'z' # reusing a moved-from value is legal
^
Exit status 139. The type's =destroy is perfectly safe; the crash comes from the next write.
The advice in the original skill was: =wasMoved only needs to make =destroy a no-op. It matches how the hook is usually described: set a pointer field to nil so the destructor has nothing to free. Here is a byte writer that does exactly that:
type
Writer = object
data: ptr UncheckedArray[char]
pos: int
capacity: int
proc `=destroy`(w: Writer) =
if w.data != nil: dealloc(w.data)
proc `=wasMoved`(w: var Writer) =
w.data = nil # destroy is now a no-op... pos and capacity stay stale
proc reserve(w: var Writer; extra: int) =
let required = w.pos + extra
if required > w.capacity:
let newCap = max(required, 64)
w.data = cast[ptr UncheckedArray[char]](realloc(w.data, newCap))
w.capacity = newCap
proc writeByte(w: var Writer; c: char) =
w.reserve(1)
w.data[w.pos] = c
inc w.pos
Write 32 bytes into a, move it into b, then write one byte into a again. The program crashes on that last write.
Why nilling the pointer is not enough
After the move, a.data is nil but a.pos is still 32 and a.capacity still 64. reserve sees that 33 fits in 64, skips the reallocation, and writeByte writes through a nil pointer.
A moved-from value in Nim is not dead. You can assign to it and call any operation your type allows on its empty state, so =wasMoved must leave every such operation working, not only =destroy. The fix is two lines:
PASSproc `=wasMoved`(w: var Writer) =
w.data = nil
w.pos = 0
w.capacity = 0
This rule survived into the first verified version of the skill. The test at the time only checked that destroying a moved-from value was safe, and it passed. A later test that reuses the moved-from value exposed it. That test now builds both variants: the fix passes, and the old hook reproduces the segfault above.
{.nodestroy.} on =dup: the right fix for the wrong reason
The verified skill told agents to put {.nodestroy.} on a =dup that copies into freshly allocated raw storage. The pragma was right. The reason it gave, that the compiler would otherwise destroy result before the caller receives it, was not. --expandArc shows the real hazard:
result.items = cast[ptr UncheckedArray[Counted]](allocImpl(...))
...
while i_1 < b.len:
i = i_1
`=sink`(result.items[i], `=dup`(b.items[i]))
The plain assignment into raw storage becomes a =sink, and =sink treats the destination as a live value. Here it is uninitialized memory from alloc. With {.nodestroy.} the value is written directly. A test now counts the hook calls: one =sink without the pragma, zero with it.
The reason matters because agents generalize from reasons. An agent told “result is destroyed early” adds the pragma where it is not needed and misses the real rule: any assignment into memory that does not yet hold a valid value runs a hook on garbage.
A =copy self-assignment guard that misses aliasing
proc `=copy`(dst: var Buf; src: Buf) =
if addr(dst) == addr(src): return # looks like a guard
if dst.data == src.data: return # compares what is owned
`=destroy`(dst)
# ... allocate and copy src.data into dst.data
For a small object, Nim may pass src by value. In the generated C, src is a copy on the stack, so its address never equals dst, even when both are the same element. The guard never fires: =destroy(dst) frees the buffer, and the copy then reads it through src.data.
The compiler removes a literal a = a, so the case that reaches the hook is one it cannot see, such as items[i] = items[j] with i == j at run time. In my minimal reproduction the program even prints the right answer. Valgrind disagrees:
Address 0x4a5f0b0 is 0 bytes inside a block of size 32 free'd
at free
...
by eqdestroy__u0__selfassign
by eqcopy__u1__selfassign
by main_u0__selfassign
ERROR SUMMARY: 4 errors from 4 contexts
Output of a minimal example I wrote for this article (-d:useMalloc), trimmed. With the data-pointer guard, valgrind reports 0 errors.
A happy-path demo and a stress test both pass this bug. Agents fall for it too. Without the skill, an agent’s container failed exactly this aliasing memory-safety check in a blind trial. With the skill, the agent’s container passed.
Checklist for your own hooks
=wasMovedresets every field an allowed operation reads. Test it by reusing a moved-from value, not only by destroying it.- Assignments into
alloced memory run hooks on garbage. Use{.nodestroy.}or write the value directly, and confirm with--expandArc. - Guard self-assignment on the owned pointer, not on addresses, and run the copy path under valgrind with
-d:useMalloc, because the output can look right.
None of these show up in review. They need a test on the legal-but-unusual path, which is why the skills are a maintained product and not a snapshot: my own first verified version had the =wasMoved rule wrong, and the next test caught it. For what wrong answers like these cost when a cheap agent writes them, see cost per solved task.
All three examples were compiled and run with Nim 2.3.1 on Linux amd64 (ORC, default settings unless noted).