← All articles

ownership hooks

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

runtimeSIGSEGV: Illegal storage access. (Attempt to read from nil?)in writeByte
  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:

writer.nim (excerpt)Nim 2.3.1
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:

verifiedmoved-from writer can be reused; prints PASS
proc `=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:

nim c --expandArc:dupBagPlainNim 2.3.1
  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

selfassign.nim (excerpt)Nim 2.3.1
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:

valgrindInvalid read of size 8eqcopy → copyMem
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

  • =wasMoved resets 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).