`^Maybe(^T)` — visible pointer transfer in Odin

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_status answers “did it work”
  • m answers “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:

^^T works mechanically. But it carries no meaning. A reader sees a double pointer and guesses: aliasing? out-parameter? optional result? ^Maybe(^T) is self-documenting. The Maybe wrapper 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.

Problem: Return codes like false don’t tell you if a pointer actually moved.
Guess wrong — double-free or use-after-send.

This may be an over simplification of your situation; but, whenever a boolean return is not enough to express the resulting state of your procedure, you can elect to use an enum as a return value.

MailboxSendStatus :: enum {
	TransferComplete,
	MailboxClosed,
	InvalidPointer,
	InvalidValue,
	// ...
}

An enum will enable you to establish important states as values and handle them accordingly upon return from the procedure. As you expressed a desire for things to be self-documenting (or rather remained unquestioning of a recommendation from an LLM of this behavior), this will also allow you to be more descriptive of the possible failure points and how they should be handled.

send :: proc(m: ^Mailbox($T), msg: ^T) -> MailboxSendStatus
    where intrinsics.type_has_field(T, "node"),
          intrinsics.type_field_type(T, "node") == list.Node {
    if msg == nil  { return .InvalidPointer }
    if msg^ == nil { return .InvalidValue }
    sync.mutex_lock(&m.mutex)
    defer sync.mutex_unlock(&m.mutex)
    if m.closed { return .MailboxClosed }
    list.push_back(&m.list, &msg.node)
    m.len += 1
    sync.cond_signal(&m.cond)
    return .TransferComplete
}

The usage code will be very similar with a few options for handling the return values. You can either handle all cases explicitly via a switch statement or choose a more general boolean-esque approach depending on your desired behaviors.

m := new(Itm)
send_status := mbox.send(&mb, m)

// option_1: use a switch statement to cover all of the possible outcomes
//           and handle them as needed. In this case, some return states are
//           considered 
switch send_status {
case .TransferComplete: // ...
case .InvalidPointer:   // ...
case .MailboxClosed, .InvalidValue: dispose(m)
}

// option_2: check if the transfer was incomplete
if send_status != .TransferComplete {
	if m != nil { dispose(m) }  // safe — m is non-nil, pointer is still mine
}
1 Like

Thanks — enum is cleaner than bool for failure reasons.

Good point.

For me they solve different problems:

  • enum → what happened (for diagnostics)
  • ^Maybe(^T) → who holds the pointer after the call (for pointer state)

I had a real bug: function reported failure, pointer was already transferred
With only a return value i have to trust that each status correctly implies pointer state
With ^Maybe(^T) I just check my own variable — no trust required

send :: proc(m: ^Mailbox($T), msg: ^Maybe(^T)) → MailboxSendStatus

Still learning what’s idiomatic in Odin — appreciate the feedback

For me they solve different problems:

  • enum → what happened (for diagnostics)
  • ^Maybe(^T) → who holds the pointer after the call (for pointer state)

I might be missing something here, but does “what happened” not also indicate the current state of the pointer?

I had a real bug: function reported failure, pointer was already transferred

This seems more likely a combination of one or more race conditions present elsewhere. Looking into sync.cond_signal(), the following information might be of use to you per the sync package (with some spelling/formatting corrections):

Cond implements a condition variable, a rendezvous point for threads waiting for signalling the occurrence of an event. Condition variables are used in conjunction with mutexes to provide a shared access to one or more shared variable.

A typical usage of condition variable is as follows. A thread that intends to modify a shared variable shall:

  1. Acquire a lock on a mutex.
  2. Modify the shared memory.
  3. Release the lock.
  4. Call cond_signal or cond_broadcast.

A thread that intends to wait on a shared variable shall:

  1. Acquire a lock on a mutex.
  2. Call cond_wait or cond_wait_with_timeout (will release the mutex).
  3. Check the condition and keep waiting in a loop if not satisfied with result.

If your intentions are similar to the typical use case, you currently have step 4 occurring prior to step 3. This is an observation and not necessarily a suggestion as it could be your desired behavior.

With only a return value i have to trust that each status correctly implies pointer state
With ^Maybe(^T) I just check my own variable — no trust required

If you cannot trust the return value, how can you trust the value of msg? If you reach the end of the procedure, then your node has been added to the list implying a transfer of ownership for the memory, yes?

1 Like

Fair points all around

There is more than one way to skin the cat…