Memory Safety, AddressSanitizer, and Logical Lifetimes in Tina
Tina is a concurrency framework built around the core principles of safety, fault tolerance, deterministic behavior, and simple yet performant. They arenβt features bolted onto the runtime, rather they shape almost every architectural decision in the framework.
Although Odin already provides a strong type system, that alone cannot guarantee the kinds of safety properties a long-running concurrent system needs. Tina instead treats safety as a layered stack, where each layer constrains what code can do, eliminating entire classes of bug before they can occur.
Hereβs what that stack looks like:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Level 5: Deterministic Simulation Testing β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Structural Checkers Β· Fault Injection Β· Seed Replay β β
β βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββ€
β Level 4: Process Lifecycle & Watchdog β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Heartbeat Monitor Β· Graceful Shutdown Β· Force Kill β β
β βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββ€
β Level 3: Three-Level Recovery Hierarchy β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββββββ β
β β L1: Isolate β β L2: Shard β β L3: Process β β
β β Restart ββ β Mass Teardownββ β Abort (OS kill) β β
β β (supervisor) β β + Rebuild β β + External rest. β β
β ββββββββ¬ββββββββ ββββββββ¬ββββββββ ββββββββββ¬ββββββββββ β
β β restart budget β quarantine β kernel β
β β exceeded β policy β reclaim β
βββββββββββ΄ββββββββββββββββββ΄ββββββββββββββββββββ΄ββββββββββββββ€
β Level 2: Trap Boundary (Hardware Fault Isolation) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Nested sigsetjmp/siglongjmp (POSIX) β β
β β Catches: SIGSEGV Β· SIGBUS Β· SIGFPE Β· SIGILL Β· panic β β
β β Outer trap ββ Shard recovery β β
β β Handler trap ββ Isolate dispatch recovery β β
β β Init trap ββ Isolate spawn failure recovery β β
β β Guard pages between each Shard's memory region β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Level 1: Memory Safety & Resource Management β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β 3-Lifetime Arena Allocator β β
β β Bounded Resources (pre-sized data structures) β β
β β Code Sanitizers (ASan, TSan, MSan) β β
β β Lock-free data structures β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Level 0: Architecture Constraints β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Shared-Nothing Β· Thread-Per-Core Β· Cooperative Sched. β β
β β Static Allocation + Boot-time Spec Validation β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Each layer is independently useful, but together they reinforce each other. This is a broader view of safety than the one often discussed in programming language forums, where safety tends to mean the type system, a borrow checkerβ or a garbage collector. Those are valuable tools, but theyβre only one layer of a much larger system.
This article focuses on Level 1 (memory safety), more specifically, how Tina integrates AddressSanitizer (ASan). The other layers provide important context because they explain why ASan alone isnβt enough, but weβll move through them quickly before diving into the implementation.
How the Safety Layers Compose
Level 0 is a conceptual layer that removes various failure domains through architectural constraints. A shared-nothing architecture eliminates data races by construction. Static allocation during startup eliminates out-of-memory failures and heap fragmentation during normal execution. Every memory requirement is declared up front, validated during startup, and the process refuses to start if a constraint is violated.
Bounded resources reinforce those guarantees. Fixed-size queues and pre-allocated data structures cannot silently grow until the process collapses under load. Instead, the system sheds work in a controlled manner. I covered the reasoning behind that design in Why Queues Donβt Fix Overload.
Level 1 builds on those guarantees with a memory model based around three scheduler-known arena lifetimes: Shard permanent, per-Isolate, and per-handler scratch.
The Shard-permanent memory lives for as long as the Shard exists. Per-Isolate memory lives only for the lifetime of an Isolate. The scratch arena exists only for the duration of a single handler invocation before being automatically reset. Together, these lifetimes dramatically reduce the scope for accidental memory misuse while keeping allocation predictable and inexpensive.
Level 2 introduces trap boundaries that recover from hardware faults such as segmentation faults, divide-by-zero, illegal instructions, and panics. Tina uses three nested recovery points:
- Isolate trap boundary: wraps each Isolate dispatch so failures affect only that Isolate. Recovery tears down the failed Isolate before execution continues.
- Shard trap boundary: wraps the scheduler loop itself. Failures outside an Isolate trigger Shard teardown and reconstruction.
- Isolate initialisation trap: wraps Isolate creation so that panics during initialisation safely roll back partially constructed state.
The supervision system, inspired by Erlang/OTP, sits at Level 3. Rather than treating every failure equally, it escalates through three recovery levels: restarting a single Isolate, rebuilding an entire Shard, and finally terminating the process so an external supervisor can restart it.
Level 4 manages the process lifecycle. A watchdog thread monitors Shards, detects failures that escape lower layers, and coordinates graceful shutdown. The scope at this layer is quite small, but I might expand its responsibilities as the framework matures.
Finally, Level 5 is Tinaβs deterministic simulation environment. It injects I/O failures, delayed messages, forced crashes, and other fault conditions while verifying structural invariants after every recovery boundary. Every simulation can be replayed exactly from its random seed, so that intermittent failures are reproducible.
The architecture embraces the βlet it crashβ philosophy. Crashes are designed to be cheap, isolated, and recoverable. The simulator then repeatedly proves those recovery paths by deliberately breaking the system under controlled conditions.
Code Sanitizers
The architectural layers prevent many bugs before the compiler gets involved. But we can also use compiler sanitizers as an additional line of defence by detecting mistakes that still occur during development.
A compiler sanitizer is a specialised dynamic analysis tool that instruments your program with additional runtime checks. Unlike static analysis which reasons about code without executing it, sanitizers observe actual execution and report problems the moment they occur.
The LLVM toolchain provides several such sanitizers, each targeting a different category of bug:
- AddressSanitizer (ASan) detects memory errors such as use-after-free, use-after-scope, use-after-return, double free, invalid free, and buffer overflows.
- LeakSanitizer (LSan) is integrated into ASan and performs leak detection just before the process exits.
- ThreadSanitizer (TSan) detects unsynchronised concurrent accesses to shared memory.
- MemorySanitizer (MSan) detects reads from uninitialised memory. Although this tends to be less useful in Odin because variables are zero-initialised by default.
Each sanitizer solves a different problem, and Tina uses them alongside both its simulation and non-simulation test suites. Theyβre also integrated into the projectβs GitHub Actions workflow so these checks run on every commit.
Of those tools, AddressSanitizer required by far the most integration work. Not because of a limitation in ASan, but because Tina deliberately breaks one of ASanβs core assumptions about memory management.
AddressSanitizer
AddressSanitizer (ASan) detects memory errors by tracking which parts of your programβs address space are currently valid to access. It does this by maintaining a parallel shadow memory, where every byte represents the state of eight bytes of application memory.1
The compiler instruments every memory access with lightweight checks against this shadow memory. When memory becomes invalid, for example after free(), ASan poisons the corresponding shadow bytes. Any subsequent read or write immediately aborts the program with a detailed diagnostic showing where the invalid access occurred.
This model works for traditional allocators because logical lifetime and physical lifetime are effectively the same. Once memory is freed, it becomes invalid.
This assumption doesnβt work for Tina because of its memory model.
The framework allocates a Grand Arena β a large memory region from which the frameworkβs allocators are created β during startup. From that point onwards, no memory is returned to the operating system until the process exits. There are no allocator alloc() or free() calls for ASan to observe.
As far as ASan is concerned, the entire arena remains valid and addressable.
The problem is that Tina still has logical lifetimes. An Isolate slot, a message envelope, or a ring buffer entry may have been released and reused many times, even though the underlying memory has never changed. A stale pointer into one of those regions can silently access a completely different logical object because the memory is still mapped and physically addressable.
ASan cannot detect that on its own because, from its perspective, nothing was ever freed.
To bridge that gap, Tina explicitly interacts with ASan about its logical ownership model by poisoning and unpoisoning memory whenever ownership changes. Instead of relying solely on physical allocation events, ASan now observes the same logical lifetime transitions that the framework itself enforces.
Manual Poisoning of Every Logical Lifetime
Every logical lifetime in Tina follows the same contract:
Poison on logical release. Unpoison on logical allocation.
Whenever ownership of a memory region ends, Tina marks it as poisoned. Whenever that region is assigned to a new logical owner, Tina marks it as unpoisoned.
The poison/unpoison operations uses the functions Odin exposes through the base:sanitizer package to hook into the ASan runtime. Tina uses address_poison_rawptr and address_unpoison_rawptr from that package to update ASanβs shadow memory as ownership changes. These operations donβt allocate, free, or move memory, they simply tell ASan whether a region should be considered addressable or not. That makes them inexpensive enough to perform at every logical lifetime transition during testing.
All of the hooks used are gated behind the compile-time constant:
TINA_ASAN_POISONING :: .Address in ODIN_SANITIZER_FLAGS
Building with -sanitize:address enables ASan instrumentation. Without it, every when TINA_ASAN_POISONING { ... } block disappears at compile time, leaving production builds with zero runtime cost and no additional binary size. The six domains below all implement the poinson/unpoison contract, each applied to a different ownership model within the framework.
1. Isolate Slots (Message Payload + Working Memory)
An Isolate slot becomes logically free the moment an Isolate exits or crashes. At that point the scheduler returns the slot to the free list and immediately poisons it. When a new Isolate is spawned into the slot, Tina unpoisons the region before any initialisation code executes.2
Without this, a stale pointer to the previous occupant silently reads or writes the new occupantβs state because the underlying memory never changed. Hardware traps cannot detect this because the memory is still mapped and physically valid. By poisoning the slot on teardown, ASan instead reports any invalid access.
2. Per-Handler Scratch Space
The per-handler scratch arena is a bump allocator whose lifetime ends with every handler invocation. Once a handler returns, the scheduler resets the arena and poisons the entire region. Subsequent allocations unpoison only the byte ranges thatβs requested.3
This catches pointers that accidentally escape a handler turn. Instead of quietly reading stale data left behind by the previous invocation, any access after the handler returns faults immediately, making the lifetime violation obvious.
3. Message Pool
Message envelopes are recycled from a fixed-size memory pool. After a message has been delivered, the scheduler returns its slot to the pool and poisons it. When that slot is reused for a new message, Tina unpoisons it again.4
A procedure that mistakenly retains a pointer after delivery would otherwise access an entirely different message later on. Because the pool operates within a single Shard, the ownership transition is straightforward: poison on explicit release, unpoison on explicit allocation.
4. I/O Buffer Slots
An I/O buffer alternates between two owners: Tina and the operating system.
Before submission, the handler owns the buffer and may freely access it. Once the request is submitted, ownership transfers to the kernel. Tina immediately poisons the entire slot and keeps it poisoned until the completion event returns ownership back to user space, at which point it unpoisons that region.5
This protects against an otherwise invisible class of bug. While an I/O request is in flight, the kernel may be reading from or writing to that memory, but the CPU has no concept of βkernel-ownedβ memory. Without poisoning, a stray write from application code silently races with the kernel, leading to unexpected behaviour. With poisoning enabled, ASan reports the first invalid access, thereby preventing such bugs. This invariant must be maintained, otherwise we run into issues that are very difficult to debug.
5. SPSC Ring Buffer Slots
The Single-Producer Single-Consumer (SPSC) ring buffer is the only domain where the ordering of the ASan hook itself becomes critical.
After the consumer finishes reading a slot, it advances a sequence number to publish that the slot is available again. Tina poisons the slot before performing that atomic store. The producer unpoisons the slot immediately before writing new data into it.6
If those two operations happened in the opposite order β publishing first, poisoning second β the producer could begin writing while ASan still considered the slot valid for the consumer. Poisoning before publication closes that race entirely.
Unlike the message pool, this isnβt an explicit free operation. Ownership changes through cross-thread coordination, so the poisoning point naturally follows the synchronisation primitive rather than a memory allocator.
6. Logging Subsystem
The logging subsystem maintains a circular buffer with independent read and write cursors. Everything between those cursors represents committed log entries that have not yet been flushed. Everything outside that region is deemed available for reuse.
Tina therefore keeps only the unflushed region unpoisoned. As the write cursor advances, newly committed entries become unpoisoned. As the read cursor flushes entries, those regions are immediately poisoned again.7
Without this, any code holding a pointer into flushed log memory region would quietly observe whichever log entry happened to occupy that location next. ASan instead reports the stale access the moment it occurs.
Across all six domains, the implementation follows the principle mentioned earlier: every logical ownership transition is mirrored in ASanβs shadow memory. By ASan learn about Tinaβs logical lifetimes instead of only its physical allocations, the framework turns various category of silent stale-pointer bugs into immediate, actionable diagnostics.
Detecting Memory Leaks
AddressSanitizer can also detect memory leaks, although LLVM still describes that feature as experimental.8
For leak detection, however, Tina primarily relies on Odinβs tracking allocator. It reports memory leaks and invalid frees, and integrates directly with Odinβs testing package. Every test in Tina runs with the tracking allocator enabled, allowing leaks to be caught during test execution without requiring a dedicated LeakSanitizer pass.
Wrapping Up
Memory safety isnβt the result of a single tool or language feature. Itβs the outcome of multiple layers working together, with each one reinforcing the guarantees provided by the others. In Tina, the architecture eliminates entire classes of bugs before the program even runs. Arena lifetimes constrain ownership. Trap boundaries contain software/hardware faults. Deterministic simulation repeatedly validates recovery paths. Compiler sanitizers then verify that the implementation faithfully respects those guarantees.
AddressSanitizer plays a particularly interesting role in that stack.
By default, ASan understands physical memory lifetimes: allocation, use, and free. Tina, on the other hand, keeps almost all of its memory allocated for the lifetime of the process. From ASanβs perspective, nothing ever becomes invalid.
The solution is to help ASan find the frameworkβs logical lifetimes instead.
Every time ownership changes β whether itβs an Isolate slot, a message envelope, an I/O buffer, a ring buffer entry, or a log buffer β Tina mirrors that transition into ASanβs shadow memory. The result is that ASan observes the same ownership rules the runtime itself enforces, exposing bugs that would otherwise remain completely invisible.
In that sense, Tina and ASan are a bit like Batman and Robin. Individually theyβre effective, but together they cover each otherβs blind spots. Tina supplies the logical ownership model; ASan does the runtime verification.
Whatβs Next
ThreadSanitizer (TSan) is already part of Tinaβs GitHub Actions pipeline, but it isnβt exercising the most interesting code yet. The current unit and simulation tests are either single-threaded or replace various platform components under the TINA_SIM=true flag, leaving some critical cross-thread coordination paths outside TSanβs reach.
Iβm currently exploring ways to improve that and hope to have a solution in place by the end of July or August.
Beyond that, Iβd like to build a dedicated machine that continuously runs deterministic simulation tests (similar to TigerBeetleβs VOPR), as well as a microbenchmark suite covering Tinaβs APIs and runtime boundaries. Watching performance and behavioural changes over time is just as valuable as catching correctness regressions.
If either of those sounds interesting, Iβd love to hear from you.
Building this kind of infrastructure takes a significant amount of time and experimentation. If youβd like to support that work, you can do so financially via GitHub Sponsors, contribute code or examples, or simply give the project a star on GitHub. Every contribution helps move the project and Odin ecosystem forward.
Finally, give Tina a try and let me know what you think. There are several examplesβincluding an HTTP serverβto help you get started.
Notes & References
Footnotes
-
AddressSanitizer algorithm: https://github.com/google/sanitizers/wiki/AddressSanitizerAlgorithm β©
-
_sanitizer_address_poison_message_slot_payload,_sanitizer_address_unpoison_message_slotβ© -
_sanitizer_address_poison_inflight_io_slot,_sanitizer_address_unpoison_reactor_io_slotβ© -
_sanitizer_address_poison_spsc_ring_slots,_sanitizer_address_unpoison_spsc_ring_slotβ© -
_sanitizer_address_poison_log_ring_region,_sanitizer_address_unpoison_log_ring_region,_sanitizer_address_refresh_log_ring_poisonβ©