I am porting Tofu — an async messaging library for Zig — to Odin. New to Odin, trying to use my Zig baggage
The original title was “Ownership moving…” — until I learned Odin does not have that concept.
TL;DR
Problem: Return codes like false don’t tell you if a pointer actually moved.
Guess wrong — double-free or use-after-send.
Fix: Pass ^Maybe(^T). After the call, nil means transferred. Non-nil means still yours.
Result:
- Variable is
nil— transferred, don’t touch it. - Variable is not
nil— still yours, you decide.
No need to check return codes to know who holds the pointer. Check your own variable.
Why inter-thread communication by pointer
Odin channels copy.
My systems are built slightly differently:
- move pool-allocated items (pointers) between threads
- via mailboxes
So: naive mailbox first.
Mailbox :: struct($T: typeid) {
list: list.List,
mutex: sync.Mutex,
cond: sync.Cond,
closed: bool,
len: int,
}
send :: proc(m: ^Mailbox($T), ptr: ^T) -> bool {
sync.mutex_lock(&m.mutex)
defer sync.mutex_unlock(&m.mutex)
if m.closed { return false }
list.push_back(&m.list, &ptr.node)
m.len += 1
sync.cond_signal(&m.cond)
return true
}
Usage:
itm := new(Itm)
send_status := mbox.send(&mb, itm)
Yes, there is a return value:
send_status == true— item is in the mailbox.send_status == false— item is not.
Start from send_status == false.
Looks simple — send failed. I still own the pointer, free it, return to pool, reuse…
But where is the pointer?
I had bad experience: message was actually transferred to another thread, but send returned false anyway. It was moved. I no longer owned it. But the caller got false and tried to clean up — double free, corruption.
I don’t smoke, so a lot of coffee…
false does not mean “you still own it.” It means “something went wrong.”
Now send_status == true.
// I was fired 2 months ago, 20 lines later, different developer:
itm.name = "oops" // compiler: fine
free(itm) // compiler: fine
mbox.send(&mb, itm) // compiler: fine, runtime: ???
Frankly speaking, I also made the same error — not just “different developer.”
Neither true nor false is safe.
The idea
Change the signature. Instead of ^T, take ^Maybe(^T).
Now the API answers both questions through the caller’s own variable.
// send adds msg to the mailbox and wakes one waiting thread.
// nil: no-op, returns false.
// closed: returns false, msg^ unchanged — transfer did not occur.
// success: returns true, msg^ = nil — transfer complete.
send :: proc(m: ^Mailbox($T), msg: ^Maybe(^T)) -> bool
where intrinsics.type_has_field(T, "node"),
intrinsics.type_field_type(T, "node") == list.Node {
if msg == nil { return false }
if msg^ == nil { return false }
ptr := (msg^).?
sync.mutex_lock(&m.mutex)
defer sync.mutex_unlock(&m.mutex)
if m.closed { return false }
list.push_back(&m.list, &ptr.node)
m.len += 1
////////////////////////////////////////////////////
msg^ = nil // this is the moment — transfer complete
////////////////////////////////////////////////////
sync.cond_signal(&m.cond)
return true
}
Usage:
m: Maybe(^Itm) = new(Itm)
send_status := mbox.send(&mb, &m)
// Problem 1: send_status == false, but was pointer moved?
// m is non-nil — pointer is still mine.
if !send_status {
if m != nil { dispose(&m) } // safe — m is non-nil, pointer is still mine
}
// Problem 2: send_status == true, pointer still accessible?
// m is nil — pointer is gone. Nothing to do, nothing to free, nothing to corrupt.
Summary:
send_statusanswers “did it work”manswers “who holds the pointer”- They are independent questions
// Q: Did send succeed? A: check send_status
if send_status { ... }
// Q: Did mailbox close? A: check send_status
if !send_status && m != nil { ... } // closed but pointer still mine
// Q: Who holds the pointer? A: check m
if m != nil { /* I hold it */ } else { /* mailbox holds it */ }
Why “send”?
The same idea applies to any pointer transfer. For pool return it is put:
put :: proc(p: ^Pool($T), itm: ^Maybe(^T)) -> (^T, bool) { ... }
Same contract: pass ^Maybe(^T), check inner after the call. One convention, used everywhere transfer happens.
Why not ^^T?
I asked Claude. The answer:
^^Tworks mechanically. But it carries no meaning. A reader sees a double pointer and guesses: aliasing? out-parameter? optional result?^Maybe(^T)is self-documenting. TheMaybewrapper announces that this value may or may not be present. The contract is in the type.
I cannot explain it better. I also barely understand it.
My feelings are unchanged. I will continue to use it.
Source of truth
In Tofu’s Zig interface, every transferring API takes *?*message.Message:
// Returns a message to the internal pool.
// Sets msg.* to null to prevent reuse.
pub fn put(ampe: Ampe, msg: *?*message.Message) void {
if (msg.* == null) { return; }
// ...
ampe.vtable.put(ampe.ptr, msg);
}
// Submits a message for async processing.
// On success: sets msg.* to null (prevents reuse).
// On error (internal failure): also sets msg.* to null.
pub fn post(chnls: ChannelGroup, msg: *?*message.Message) !message.BinaryHeader
First time you see *?*message.Message in my Zig code: WTH?
After that: you never want to see double pointers in transfer again.
^Maybe(^T) in Odin:
- different syntax
- same idea
- same guarantee: after the call, you know exactly who holds the pointer
Quiz
dispose :: proc(itm: ^Maybe(^Itm)) {
if itm == nil { return }
if itm^ == nil { return }
ptr := (itm^).?
// cleanup ptr fields
// free ptr itself
itm^ = nil
}
m: Maybe(^Itm) = new(Itm)
defer dispose(&m)
send_status := mbox.send(&mb, &m)
Also WTH?
But that is a different post.
Reviewed with Claude (Anthropic). All bugs mine. Humans make mistakes — double-check.