commit 5a3b38321c0ebee3d9d6a5259be5d72069b08cca
parent 95faf8ee1e90ee174a3a590abafecc77bd6d3af1
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Wed, 23 Sep 2026 21:15:12 -0300
debug: add the guard-byte allocator copied from sonar
Scripts never free, so the one allocator bug class left is a write past
a block or into a freed one. This debug allocator wraps a backing
allocator with guard bytes, quarantine and poison, and reports overflow,
double free, write after free and leaks with the allocation site, so a
reader can fix the bug from the output alone. It is the copy sonar now
imports; a later commit lets a script swap it in behind one flag.
Diffstat:
| A | debug/debug.odin | | | 1411 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 1411 insertions(+), 0 deletions(-)
diff --git a/debug/debug.odin b/debug/debug.odin
@@ -0,0 +1,1411 @@
+/*
+Debug allocator: finds allocation bugs and reports them so that a reader with no
+context, human or AI agent, can locate and fix them from the output alone.
+
+What it detects, and when:
+
+ OVERFLOW / UNDERFLOW guard bytes around every block are checked on free, on
+ resize, at every phase change, at every checkpoint, and at
+ exit. The report says how many bytes past which end were
+ clobbered and what was written there.
+ DOUBLE_FREE freed blocks sit in a quarantine instead of returning to the
+ backing allocator, so a second free is recognised and both
+ free sites are reported.
+ WRITE_AFTER_FREE freed memory is poisoned; when a block leaves quarantine, at
+ a phase change, at a checkpoint, or at exit, the poison is
+ verified.
+ BAD_FREE / BAD_RESIZE the pointer was never handed out by this allocator.
+ SIZE_MISMATCH delete() called with a length that differs from the one
+ allocated, the usual sign of a re-sliced slice.
+ stale after resize a resize always moves the block; the old address is
+ quarantined and poisoned, so a pointer kept across an append
+ surfaces as WRITE_AFTER_FREE or DOUBLE_FREE naming the resize.
+ LEAK blocks still live at exit, grouped by allocation site.
+
+Every issue is printed in full the moment it is detected, because a corrupted heap
+may crash the program before any exit report. The exit report indexes the issues,
+lists leak groups, and prints per-site lifetime statistics: how many allocations a
+site made, how long they lived (in allocation ticks, so the number is the same from
+run to run), and whether they died in the phase they were born in.
+
+Fresh non-zeroed memory is filled with 0xCD, freed memory with 0xDD, and guards with
+0xFD. Seeing 0xCDCDCDCD or 0xDDDDDDDD in a value tells you which kind of bug you are
+looking at.
+
+With -sanitize:address the allocator also poisons its guards and every freed block
+through base:sanitizer, so an instrumented read or write into them traps at the
+faulting instruction instead of being noticed at the next check. That closes the gap
+a pure allocator has: reads past the end and reads after free. On Windows this is the
+only way ASan sees Odin heap blocks at all, because the default allocator uses
+HeapAlloc rather than the libc malloc that ASan intercepts. When ASan reports, a
+death callback appends which block the address belongs to, where it was allocated,
+and where it was freed, since ASan itself only knows the address is poisoned.
+
+Usage (debug builds only, compile with -debug so call chains symbolize):
+
+ import "core:debug/trace"
+ import "jfm:debug"
+
+ main :: proc() {
+ when ODIN_DEBUG {
+ da: debug.Allocator
+ debug.init(&da, context.allocator)
+ defer debug.destroy(&da)
+ defer debug.report(&da)
+ context.allocator = debug.allocator(&da)
+ context.assertion_failure_proc = trace.assertion_failure_proc
+ }
+ run()
+ }
+
+ debug.set_phase(&da, "scan") // label program stages; each change is a checkpoint
+ debug.check(&da) // verify every live and quarantined block right now
+
+Compile-time knobs:
+
+ -define:DEBUG_ALLOC_FAIL_FAST=true panic at the first issue, after printing it
+ -define:DEBUG_ALLOC_QUARANTINE=<bytes> freed memory held back (default 16 MiB)
+ -define:DEBUG_ALLOC_GUARD=<bytes> guard size each side of a block (default 16)
+ -define:DEBUG_ALLOC_BACKTRACES=false skip call-chain capture in allocation-heavy programs
+
+Phase names must outlive the allocator; pass string literals.
+*/
+package debug
+
+import "base:runtime"
+import "base:sanitizer"
+import "core:debug/trace"
+import "core:fmt"
+import "core:mem"
+import "core:path/filepath"
+import "core:slice"
+import "core:strings"
+import "core:sync"
+
+GUARD :: #config(DEBUG_ALLOC_GUARD, 16)
+QUARANTINE_BYTES :: #config(DEBUG_ALLOC_QUARANTINE, 16 * 1024 * 1024)
+FAIL_FAST :: #config(DEBUG_ALLOC_FAIL_FAST, false)
+BACKTRACES :: #config(DEBUG_ALLOC_BACKTRACES, true)
+
+// True when the program was built with -sanitize:address.
+ASAN :: .Address in ODIN_SANITIZER_FLAGS
+
+PATTERN_GUARD :: 0xFD
+PATTERN_DEAD :: 0xDD
+PATTERN_FRESH :: 0xCD
+
+#assert(
+ GUARD >= 8 && (GUARD & (GUARD - 1)) == 0,
+ "DEBUG_ALLOC_GUARD must be a power of two of at least 8",
+)
+
+Issue_Kind :: enum {
+ Overflow,
+ Underflow,
+ Double_Free,
+ Write_After_Free,
+ Bad_Free,
+ Bad_Resize,
+ Size_Mismatch,
+}
+
+// Where and when something happened to a block.
+Site :: struct {
+ loc: runtime.Source_Code_Location,
+ phase: string,
+ seq: u64, // allocation tick at the time
+ bt: trace.Capture_Const,
+}
+
+@(private)
+Live_Block :: struct {
+ raw: rawptr, // start of the front guard
+ total: int, // front + size + GUARD
+ front: int, // front guard size; at least GUARD, grows to satisfy alignment
+ size: int,
+ alignment: int,
+ alloc: Site,
+ resizes: int,
+ last_resize: runtime.Source_Code_Location,
+}
+
+@(private)
+Dead_Block :: struct {
+ raw: rawptr,
+ total: int,
+ front: int,
+ size: int,
+ alloc: Site,
+ free: Site,
+ by_resize: bool, // freed because a resize moved the block
+}
+
+Corruption :: struct {
+ offset: int, // first bad byte relative to the start of the user block; negative for underflow
+ count: int,
+ sample: [8]byte,
+}
+
+Issue :: struct {
+ id: int,
+ kind: Issue_Kind,
+ ptr: rawptr,
+ size: int,
+ alloc: Site,
+ has_alloc: bool,
+ first_free: Site,
+ has_first_free: bool,
+ by_resize: bool,
+ op: Site, // the operation during which the issue was detected
+ stage: string, // "free", "resize", "phase change", "checkpoint", "quarantine", "exit"
+ corruption: Corruption,
+ given_size: int, // Size_Mismatch
+}
+
+@(private)
+Site_Key :: struct {
+ file: string,
+ line: i32,
+}
+
+Site_Stats :: struct {
+ loc: runtime.Source_Code_Location,
+ allocs: int,
+ frees: int,
+ resizes: int,
+ bytes: int, // total bytes ever allocated here
+ live: int,
+ live_bytes: int,
+ peak_live_bytes: int,
+ lifetime_min: u64,
+ lifetime_max: u64,
+ lifetime_sum: u64,
+ cross_phase: int, // frees that happened in a different phase than the allocation
+ last_death: string, // phase of the most recent free
+}
+
+Allocator :: struct {
+ backing: mem.Allocator,
+ internals: mem.Allocator, // bookkeeping; never the tracked allocator itself
+ live: map[rawptr]Live_Block,
+ dead: map[rawptr]Dead_Block,
+ dead_queue: [dynamic]rawptr, // FIFO for quarantine eviction
+ dead_head: int,
+ dead_bytes: int,
+ sites: map[Site_Key]Site_Stats,
+ issues: [dynamic]Issue,
+ phases: [dynamic]string,
+ phase: string,
+ seq: u64,
+ total_allocs: int,
+ total_frees: int,
+ live_bytes: int,
+ peak_bytes: int,
+ fail_fast: bool,
+ mutex: sync.Mutex,
+ root: string, // directory of the file that called init; paths print relative to it
+}
+
+init :: proc(
+ da: ^Allocator,
+ backing: mem.Allocator,
+ internals := context.allocator,
+ loc := #caller_location,
+) {
+ da.backing = backing
+ da.internals = internals
+ da.live.allocator = internals
+ da.dead.allocator = internals
+ da.dead_queue.allocator = internals
+ da.sites.allocator = internals
+ da.issues.allocator = internals
+ da.phases.allocator = internals
+ da.phase = "startup"
+ append(&da.phases, da.phase)
+ da.fail_fast = FAIL_FAST
+ da.root = normalize(filepath.dir(loc.file_path), internals)
+ when ASAN {
+ asan_owner = da
+ sanitizer.address_set_death_callback(asan_death)
+ }
+}
+
+// Release everything, including blocks the program never freed. Call report first.
+destroy :: proc(da: ^Allocator) {
+ sync.guard(&da.mutex)
+ for _, l in da.live {
+ backing_free(da, l.raw, l.total)
+ }
+ for _, d in da.dead {
+ backing_free(da, d.raw, d.total)
+ }
+ when ASAN {
+ if asan_owner == da {
+ asan_owner = nil
+ }
+ }
+ delete(da.live)
+ delete(da.dead)
+ delete(da.dead_queue)
+ delete(da.sites)
+ delete(da.issues)
+ delete(da.phases)
+ delete(da.root, da.internals)
+ da^ = {}
+}
+
+allocator :: proc(da: ^Allocator) -> mem.Allocator {
+ return mem.Allocator{procedure = allocator_proc, data = da}
+}
+
+// Mark the start of a program stage. Every live and quarantined block is verified
+// first, so corruption is pinned to the stage it happened in. Pass a string literal.
+set_phase :: proc(da: ^Allocator, name: string, loc := #caller_location) {
+ context.allocator = da.internals
+ sync.guard(&da.mutex)
+ verify_all(da, "phase change", loc)
+ da.phase = name
+ append(&da.phases, name)
+}
+
+// Verify every live and quarantined block right now. Sprinkle calls to narrow down
+// where a corruption happens.
+check :: proc(da: ^Allocator, loc := #caller_location) {
+ context.allocator = da.internals
+ sync.guard(&da.mutex)
+ verify_all(da, "checkpoint", loc)
+}
+
+issue_count :: proc(da: ^Allocator) -> int {
+ sync.guard(&da.mutex)
+ return len(da.issues)
+}
+
+// ---- allocator ------------------------------------------------------------------
+
+allocator_proc :: proc(
+ data: rawptr,
+ mode: mem.Allocator_Mode,
+ size, alignment: int,
+ old_memory: rawptr,
+ old_size: int,
+ loc := #caller_location,
+) -> (
+ result: []byte,
+ err: mem.Allocator_Error,
+) {
+ da := (^Allocator)(data)
+ // Anything this allocator does internally must not come back through itself.
+ context.allocator = da.internals
+ sync.guard(&da.mutex)
+
+ switch mode {
+ case .Alloc, .Alloc_Non_Zeroed:
+ return do_alloc(da, size, alignment, mode == .Alloc, loc)
+ case .Free:
+ return nil, do_free(da, old_memory, old_size, loc)
+ case .Free_All:
+ do_free_all(da, loc)
+ return nil, nil
+ case .Resize, .Resize_Non_Zeroed:
+ return do_resize(da, old_memory, old_size, size, alignment, mode == .Resize, loc)
+ case .Query_Features:
+ if set := (^mem.Allocator_Mode_Set)(old_memory); set != nil {
+ set^ = {
+ .Alloc,
+ .Alloc_Non_Zeroed,
+ .Free,
+ .Free_All,
+ .Resize,
+ .Resize_Non_Zeroed,
+ .Query_Features,
+ }
+ }
+ return nil, nil
+ case .Query_Info:
+ return nil, .Mode_Not_Implemented
+ }
+ return nil, .Mode_Not_Implemented
+}
+
+// Obtain a guarded block from the backing allocator. No bookkeeping.
+@(private)
+raw_alloc :: proc(
+ da: ^Allocator,
+ size, alignment: int,
+ zeroed: bool,
+ loc: runtime.Source_Code_Location,
+) -> (
+ l: Live_Block,
+ user: []byte,
+ err: mem.Allocator_Error,
+) {
+ if size < 0 {
+ return {}, nil, .Invalid_Argument
+ }
+ align := max(alignment, 1)
+ // The front guard doubles as alignment padding: the backing block is aligned to
+ // max(align, GUARD) and the user block starts `front` bytes in, where `front` is a
+ // multiple of `align`.
+ front := max(GUARD, align)
+ total := front + size + GUARD
+ raw, alloc_err := mem.alloc_bytes_non_zeroed(total, max(align, GUARD), da.backing, loc)
+ if alloc_err != nil {
+ return {}, nil, alloc_err
+ }
+ mem.set(raw_data(raw), PATTERN_GUARD, front)
+ user = raw[front:front + size]
+ mem.set(raw_data(raw[front + size:]), PATTERN_GUARD, GUARD)
+ if zeroed {
+ mem.zero_slice(user)
+ } else {
+ mem.set(raw_data(user), PATTERN_FRESH, size)
+ }
+ sanitizer.address_poison(raw[:front])
+ sanitizer.address_poison(raw[front + size:])
+ l = Live_Block {
+ raw = raw_data(raw),
+ total = total,
+ front = front,
+ size = size,
+ alignment = align,
+ }
+ return l, user, nil
+}
+
+@(private)
+do_alloc :: proc(
+ da: ^Allocator,
+ size, alignment: int,
+ zeroed: bool,
+ loc: runtime.Source_Code_Location,
+) -> (
+ []byte,
+ mem.Allocator_Error,
+) {
+ l, user, alloc_err := raw_alloc(da, size, alignment, zeroed, loc)
+ if alloc_err != nil {
+ return nil, alloc_err
+ }
+ da.seq += 1
+ l.alloc = make_site(da, loc)
+ da.live[raw_data(user)] = l
+ da.total_allocs += 1
+ da.live_bytes += size
+ da.peak_bytes = max(da.peak_bytes, da.live_bytes)
+
+ st := site_stats(da, loc)
+ st.allocs += 1
+ st.bytes += size
+ st.live += 1
+ st.live_bytes += size
+ st.peak_live_bytes = max(st.peak_live_bytes, st.live_bytes)
+ return user, nil
+}
+
+@(private)
+do_free :: proc(
+ da: ^Allocator,
+ ptr: rawptr,
+ given_size: int,
+ loc: runtime.Source_Code_Location,
+) -> mem.Allocator_Error {
+ if ptr == nil {
+ return nil
+ }
+ l, ok := da.live[ptr]
+ if !ok {
+ op := make_site(da, loc)
+ if d, dead_ok := da.dead[ptr]; dead_ok {
+ raise(
+ da,
+ Issue {
+ kind = .Double_Free,
+ ptr = ptr,
+ size = d.size,
+ alloc = d.alloc,
+ has_alloc = true,
+ first_free = d.free,
+ has_first_free = true,
+ by_resize = d.by_resize,
+ op = op,
+ stage = "free",
+ },
+ )
+ } else {
+ raise(da, Issue{kind = .Bad_Free, ptr = ptr, op = op, stage = "free"})
+ }
+ return .Invalid_Pointer
+ }
+ if given_size != 0 && given_size != l.size {
+ raise(
+ da,
+ Issue {
+ kind = .Size_Mismatch,
+ ptr = ptr,
+ size = l.size,
+ given_size = given_size,
+ alloc = l.alloc,
+ has_alloc = true,
+ op = make_site(da, loc),
+ stage = "free",
+ },
+ )
+ }
+ verify_guards(da, l, "free", loc)
+ retire(da, l, ptr, loc, false)
+ return nil
+}
+
+@(private)
+do_free_all :: proc(da: ^Allocator, loc: runtime.Source_Code_Location) {
+ for _, l in da.live {
+ verify_guards(da, l, "free_all", loc)
+ st := site_stats(da, l.alloc.loc)
+ st.frees += 1
+ st.live -= 1
+ st.live_bytes -= l.size
+ backing_free(da, l.raw, l.total)
+ }
+ da.total_frees += len(da.live)
+ da.live_bytes = 0
+ clear(&da.live)
+ flush_quarantine(da, "free_all", loc)
+}
+
+@(private)
+do_resize :: proc(
+ da: ^Allocator,
+ ptr: rawptr,
+ old_size, size, alignment: int,
+ zeroed: bool,
+ loc: runtime.Source_Code_Location,
+) -> (
+ []byte,
+ mem.Allocator_Error,
+) {
+ if ptr == nil {
+ return do_alloc(da, size, alignment, zeroed, loc)
+ }
+ if size == 0 {
+ return nil, do_free(da, ptr, old_size, loc)
+ }
+ l, ok := da.live[ptr]
+ if !ok {
+ op := make_site(da, loc)
+ if d, dead_ok := da.dead[ptr]; dead_ok {
+ raise(
+ da,
+ Issue {
+ kind = .Double_Free,
+ ptr = ptr,
+ size = d.size,
+ alloc = d.alloc,
+ has_alloc = true,
+ first_free = d.free,
+ has_first_free = true,
+ by_resize = d.by_resize,
+ op = op,
+ stage = "resize",
+ },
+ )
+ } else {
+ raise(da, Issue{kind = .Bad_Resize, ptr = ptr, op = op, stage = "resize"})
+ }
+ return nil, .Invalid_Pointer
+ }
+ if old_size != 0 && old_size != l.size {
+ raise(
+ da,
+ Issue {
+ kind = .Size_Mismatch,
+ ptr = ptr,
+ size = l.size,
+ given_size = old_size,
+ alloc = l.alloc,
+ has_alloc = true,
+ op = make_site(da, loc),
+ stage = "resize",
+ },
+ )
+ }
+ verify_guards(da, l, "resize", loc)
+
+ // Always move. A stale pointer into the old block then shows up as a
+ // write-after-free or double-free that names this resize.
+ nl, user, alloc_err := raw_alloc(da, size, max(alignment, l.alignment), false, loc)
+ if alloc_err != nil {
+ return nil, alloc_err
+ }
+ copy(user, mem.byte_slice(ptr, min(size, l.size)))
+ if size > l.size {
+ tail := user[l.size:]
+ if zeroed {
+ mem.zero_slice(tail)
+ } else {
+ mem.set(raw_data(tail), PATTERN_FRESH, len(tail))
+ }
+ }
+ // The new block inherits the identity of the original allocation so leak and
+ // lifetime reports point at where the object was created, not where it last grew.
+ nl.alloc = l.alloc
+ nl.resizes = l.resizes + 1
+ nl.last_resize = loc
+ da.live[raw_data(user)] = nl
+ da.live_bytes += size
+ da.peak_bytes = max(da.peak_bytes, da.live_bytes)
+ orig := site_stats(da, l.alloc.loc)
+ orig.resizes += 1
+ orig.bytes += size - l.size
+ orig.live_bytes += size
+ orig.peak_live_bytes = max(orig.peak_live_bytes, orig.live_bytes)
+
+ retire(da, l, ptr, loc, true)
+ return user, nil
+}
+
+// Move a live block into quarantine: poison it, record when and where it died.
+@(private)
+retire :: proc(
+ da: ^Allocator,
+ l: Live_Block,
+ ptr: rawptr,
+ loc: runtime.Source_Code_Location,
+ by_resize: bool,
+) {
+ mem.set(ptr, PATTERN_DEAD, l.size)
+ // From the user block to the end of the backing allocation is now off limits.
+ sanitizer.address_poison(ptr, l.total - l.front)
+ delete_key(&da.live, ptr)
+ da.live_bytes -= l.size
+ free_site := make_site(da, loc)
+
+ st := site_stats(da, l.alloc.loc)
+ st.live_bytes -= l.size
+ if !by_resize {
+ da.total_frees += 1
+ st.frees += 1
+ st.live -= 1
+ age := da.seq - l.alloc.seq
+ if st.frees == 1 || age < st.lifetime_min {
+ st.lifetime_min = age
+ }
+ st.lifetime_max = max(st.lifetime_max, age)
+ st.lifetime_sum += age
+ if da.phase != l.alloc.phase {
+ st.cross_phase += 1
+ }
+ st.last_death = da.phase
+ }
+
+ da.dead[ptr] = Dead_Block {
+ raw = l.raw,
+ total = l.total,
+ front = l.front,
+ size = l.size,
+ alloc = l.alloc,
+ free = free_site,
+ by_resize = by_resize,
+ }
+ append(&da.dead_queue, ptr)
+ da.dead_bytes += l.total
+
+ for da.dead_bytes > QUARANTINE_BYTES && da.dead_head < len(da.dead_queue) {
+ evict_oldest(da, "quarantine", loc)
+ }
+ // Compact the queue once the consumed prefix dominates.
+ if da.dead_head > 1024 && da.dead_head * 2 > len(da.dead_queue) {
+ n := copy(da.dead_queue[:], da.dead_queue[da.dead_head:])
+ resize(&da.dead_queue, n)
+ da.dead_head = 0
+ }
+}
+
+@(private)
+evict_oldest :: proc(da: ^Allocator, stage: string, loc: runtime.Source_Code_Location) {
+ ptr := da.dead_queue[da.dead_head]
+ da.dead_head += 1
+ d, ok := da.dead[ptr]
+ if !ok {
+ return
+ }
+ verify_poison(da, d, ptr, stage, loc)
+ backing_free(da, d.raw, d.total)
+ da.dead_bytes -= d.total
+ delete_key(&da.dead, ptr)
+}
+
+@(private)
+flush_quarantine :: proc(da: ^Allocator, stage: string, loc: runtime.Source_Code_Location) {
+ for da.dead_head < len(da.dead_queue) {
+ evict_oldest(da, stage, loc)
+ }
+ clear(&da.dead_queue)
+ da.dead_head = 0
+ da.dead_bytes = 0
+}
+
+// The backing allocator will hand this memory out again to code that is not ours,
+// so every trace of poison must be gone before it goes back.
+@(private)
+backing_free :: proc(da: ^Allocator, raw: rawptr, total: int) {
+ sanitizer.address_unpoison(raw, total)
+ da.backing.procedure(da.backing.data, .Free, 0, 0, raw, total)
+}
+
+// Overwrite a poisoned region without tripping the sanitizer, then re-poison it.
+@(private)
+refill :: proc(ptr: rawptr, len: int, pattern: byte) {
+ sanitizer.address_unpoison(ptr, len)
+ mem.set(ptr, pattern, len)
+ sanitizer.address_poison(ptr, len)
+}
+
+// ---- verification ---------------------------------------------------------------
+
+@(private)
+verify_all :: proc(da: ^Allocator, stage: string, loc: runtime.Source_Code_Location) {
+ for _, l in da.live {
+ verify_guards(da, l, stage, loc)
+ }
+ for ptr, d in da.dead {
+ verify_poison(da, d, ptr, stage, loc)
+ }
+}
+
+@(private)
+verify_guards :: proc(da: ^Allocator, l: Live_Block, stage: string, loc: runtime.Source_Code_Location) {
+ block := mem.byte_slice(l.raw, l.total)
+ user_ptr := rawptr(uintptr(l.raw) + uintptr(l.front))
+ if c, bad := scan(block[:l.front], PATTERN_GUARD); bad {
+ c.offset -= l.front
+ raise(
+ da,
+ Issue {
+ kind = .Underflow,
+ ptr = user_ptr,
+ size = l.size,
+ alloc = l.alloc,
+ has_alloc = true,
+ op = make_site(da, loc),
+ stage = stage,
+ corruption = c,
+ },
+ )
+ refill(l.raw, l.front, PATTERN_GUARD) // report each corruption once
+ }
+ if c, bad := scan(block[l.front + l.size:], PATTERN_GUARD); bad {
+ c.offset += l.size
+ raise(
+ da,
+ Issue {
+ kind = .Overflow,
+ ptr = user_ptr,
+ size = l.size,
+ alloc = l.alloc,
+ has_alloc = true,
+ op = make_site(da, loc),
+ stage = stage,
+ corruption = c,
+ },
+ )
+ refill(raw_data(block[l.front + l.size:]), GUARD, PATTERN_GUARD)
+ }
+}
+
+@(private)
+verify_poison :: proc(
+ da: ^Allocator,
+ d: Dead_Block,
+ ptr: rawptr,
+ stage: string,
+ loc: runtime.Source_Code_Location,
+) {
+ if c, bad := scan(mem.byte_slice(ptr, d.size), PATTERN_DEAD); bad {
+ raise(
+ da,
+ Issue {
+ kind = .Write_After_Free,
+ ptr = ptr,
+ size = d.size,
+ alloc = d.alloc,
+ has_alloc = true,
+ first_free = d.free,
+ has_first_free = true,
+ by_resize = d.by_resize,
+ op = make_site(da, loc),
+ stage = stage,
+ corruption = c,
+ },
+ )
+ refill(ptr, d.size, PATTERN_DEAD)
+ }
+}
+
+// Find bytes that differ from `pattern`. Returns the first offset, how many differ,
+// and up to eight of the offending values.
+@(private, no_sanitize_address)
+scan :: proc(b: []byte, pattern: byte) -> (c: Corruption, bad: bool) {
+ first := -1
+ for x, i in b {
+ if x == pattern {
+ continue
+ }
+ if first < 0 {
+ first = i
+ }
+ if c.count < len(c.sample) {
+ c.sample[c.count] = x
+ }
+ c.count += 1
+ }
+ if first < 0 {
+ return {}, false
+ }
+ c.offset = first
+ return c, true
+}
+
+// ---- bookkeeping helpers --------------------------------------------------------
+
+@(private)
+make_site :: proc(da: ^Allocator, loc: runtime.Source_Code_Location) -> Site {
+ s := Site {
+ loc = loc,
+ phase = da.phase,
+ seq = da.seq,
+ }
+ when BACKTRACES {
+ s.bt = trace.capture()
+ }
+ return s
+}
+
+@(private)
+site_stats :: proc(da: ^Allocator, loc: runtime.Source_Code_Location) -> ^Site_Stats {
+ key := Site_Key {
+ file = loc.file_path,
+ line = loc.line,
+ }
+ st, ok := &da.sites[key]
+ if !ok {
+ da.sites[key] = Site_Stats {
+ loc = loc,
+ }
+ st = &da.sites[key]
+ }
+ return st
+}
+
+@(private)
+raise :: proc(da: ^Allocator, issue: Issue) {
+ iss := issue
+ iss.id = len(da.issues) + 1
+ append(&da.issues, iss)
+ print_issue(da, iss)
+ if da.fail_fast {
+ panic("debug allocator: allocation bug detected, see the issue printed above")
+ }
+}
+
+// ---- address sanitizer ----------------------------------------------------------
+
+// The allocator whose blocks the death callback describes. One debug allocator per
+// process is the intended use.
+@(private)
+asan_owner: ^Allocator
+
+/*
+Runs while ASan is printing its report, before the process aborts. ASan knows the
+address is poisoned but not what it was; this adds the block, its allocation site,
+and its free site, which is what the reader needs to fix the bug.
+*/
+@(private)
+asan_death :: proc "c" (
+ pc, bp, sp, addr_unused: rawptr,
+ is_write_unused: i32,
+ access_size_unused: uint,
+) {
+ // The runtime invokes this without arguments; the parameters hold whatever was in
+ // the registers. The report accessors are the reliable source.
+ _, _, _, _, _, _ = pc, bp, sp, addr_unused, is_write_unused, access_size_unused
+ context = runtime.default_context()
+ da := asan_owner
+ if da == nil || !sanitizer.address_report_present() {
+ return
+ }
+ addr := sanitizer.address_get_report_address()
+ is_write := sanitizer.address_get_report_access_type() == .write
+ access_size := sanitizer.address_get_report_access_size()
+ context.allocator = da.internals
+ // The fault may have happened inside the allocator with the mutex held; describing
+ // the address is worth more than strict locking in a process that is about to die.
+ locked := sync.mutex_try_lock(&da.mutex)
+ defer if locked {
+ sync.mutex_unlock(&da.mutex)
+ }
+
+ fmt.eprintln()
+ fmt.eprintfln(
+ "== debug allocator: about the faulting address %p (%s of %d byte(s)) ==",
+ addr,
+ "write" if is_write else "read",
+ access_size,
+ )
+ a := uintptr(addr)
+ for user, l in da.live {
+ lo := uintptr(l.raw)
+ if a < lo || a >= lo + uintptr(l.total) {
+ continue
+ }
+ describe_offset(a, uintptr(user), l.size)
+ fmt.eprintln(" the block is still live")
+ print_site(da, "allocated ", l.alloc)
+ if l.resizes > 0 {
+ fmt.eprintfln(
+ " resized %d time(s), last at %s:%d",
+ l.resizes,
+ display_path(da, l.last_resize.file_path),
+ l.last_resize.line,
+ )
+ }
+ fmt.eprintln(
+ " meaning an access outside a live block's bounds. Check the index or length used for this access against the allocation size above.",
+ )
+ return
+ }
+ for user, d in da.dead {
+ lo := uintptr(d.raw)
+ if a < lo || a >= lo + uintptr(d.total) {
+ continue
+ }
+ describe_offset(a, uintptr(user), d.size)
+ print_site(da, "allocated ", d.alloc)
+ print_site(da, "moved by " if d.by_resize else "freed ", d.free)
+ if d.by_resize {
+ fmt.eprintln(
+ " meaning the block was moved by a resize and this access used the old address. A pointer or slice into a dynamic array was kept across an append; re-fetch it after growing.",
+ )
+ } else {
+ fmt.eprintln(
+ " meaning use after free. The accessor holds a dangling pointer to a block freed at the site above.",
+ )
+ }
+ return
+ }
+ fmt.eprintln(
+ " not inside any block this allocator has handed out or is holding in quarantine",
+ )
+ fmt.eprintfln(" phase=%s tick=%d issues so far=%d", da.phase, da.seq, len(da.issues))
+}
+
+@(private)
+describe_offset :: proc(a, user: uintptr, size: int) {
+ switch {
+ case a < user:
+ fmt.eprintfln(" %d byte(s) before the start of a %d-byte block", user - a, size)
+ case a >= user + uintptr(size):
+ fmt.eprintfln(
+ " %d byte(s) past the end of a %d-byte block",
+ a - (user + uintptr(size)),
+ size,
+ )
+ case:
+ fmt.eprintfln(" %d byte(s) into a %d-byte block", a - user, size)
+ }
+}
+
+// ---- reporting ------------------------------------------------------------------
+
+@(private)
+kind_name :: proc(k: Issue_Kind) -> string {
+ switch k {
+ case .Overflow:
+ return "OVERFLOW"
+ case .Underflow:
+ return "UNDERFLOW"
+ case .Double_Free:
+ return "DOUBLE_FREE"
+ case .Write_After_Free:
+ return "WRITE_AFTER_FREE"
+ case .Bad_Free:
+ return "BAD_FREE"
+ case .Bad_Resize:
+ return "BAD_RESIZE"
+ case .Size_Mismatch:
+ return "SIZE_MISMATCH"
+ }
+ return "UNKNOWN"
+}
+
+@(private)
+print_issue :: proc(da: ^Allocator, iss: Issue) {
+ fmt.eprintfln(
+ "!! ALLOCATION ISSUE #%d: %s ptr=%p size=%d b detected during %s",
+ iss.id,
+ kind_name(iss.kind),
+ iss.ptr,
+ iss.size,
+ iss.stage,
+ )
+ if iss.has_alloc {
+ print_site(da, "allocated ", iss.alloc)
+ }
+ if iss.has_first_free {
+ label := "moved by " if iss.by_resize else "freed "
+ print_site(da, label, iss.first_free)
+ }
+ op_label := "detected "
+ #partial switch iss.kind {
+ case .Double_Free, .Bad_Free:
+ op_label = "this free "
+ case .Bad_Resize:
+ op_label = "this resize"
+ case .Size_Mismatch:
+ op_label = "freed at "
+ }
+ print_site(da, op_label, iss.op)
+
+ c := iss.corruption
+ switch iss.kind {
+ case .Overflow:
+ fmt.eprintfln(
+ " damage %d byte(s) written starting %d byte(s) past the end of the block: %s (guard bytes should read %02x)",
+ c.count,
+ c.offset - iss.size,
+ hex(c),
+ PATTERN_GUARD,
+ )
+ fmt.eprintln(
+ " meaning something wrote beyond the allocation's last byte between allocation and this check. Look for an off-by-one in a loop bound or a length computed from the wrong variable.",
+ )
+ case .Underflow:
+ fmt.eprintfln(
+ " damage %d byte(s) written starting %d byte(s) before the block: %s (guard bytes should read %02x)",
+ c.count,
+ -c.offset,
+ hex(c),
+ PATTERN_GUARD,
+ )
+ fmt.eprintln(
+ " meaning something wrote before the allocation's first byte. Look for a negative index or pointer arithmetic that steps back past the start.",
+ )
+ case .Double_Free:
+ if iss.by_resize {
+ fmt.eprintln(
+ " meaning this address was invalidated when the block was resized (see 'moved by' above); the caller kept the old address across an append or resize. Re-fetch the pointer after growing the container, or reserve capacity up front.",
+ )
+ } else {
+ fmt.eprintln(
+ " meaning the block was already freed. Remove one of the two frees, or set the pointer to nil after the first so a second free is a no-op.",
+ )
+ }
+ case .Write_After_Free:
+ fmt.eprintfln(
+ " damage %d byte(s) written at offset %d after the block was freed: %s (freed memory should read %02x)",
+ c.count,
+ c.offset,
+ hex(c),
+ PATTERN_DEAD,
+ )
+ if iss.by_resize {
+ fmt.eprintln(
+ " meaning the block was moved by a resize (see 'moved by' above) and the old address was written afterwards. A pointer or slice into a dynamic array was kept across an append.",
+ )
+ } else {
+ fmt.eprintln(
+ " meaning memory was written after it was freed. The writer holds a dangling pointer; find who still references this block after the free above.",
+ )
+ }
+ case .Bad_Free, .Bad_Resize:
+ fmt.eprintln(
+ " meaning this pointer was never returned by this allocator. Common causes: freeing memory from an arena or temp allocator with the context allocator, freeing a pointer into the middle of a block, or freeing an uninitialised pointer.",
+ )
+ case .Size_Mismatch:
+ fmt.eprintfln(
+ " damage freed with size %d b but %d b were allocated",
+ iss.given_size,
+ iss.size,
+ )
+ fmt.eprintln(
+ " meaning delete() was given a slice whose length differs from the allocation, usually a re-sliced or truncated slice. Delete the original slice, or keep the original length.",
+ )
+ }
+}
+
+@(private)
+hex :: proc(c: Corruption) -> string {
+ sb := strings.builder_make(context.temp_allocator)
+ for i in 0 ..< min(c.count, len(c.sample)) {
+ if i > 0 {
+ strings.write_byte(&sb, ' ')
+ }
+ fmt.sbprintf(&sb, "%02x", c.sample[i])
+ }
+ if c.count > len(c.sample) {
+ strings.write_string(&sb, " ...")
+ }
+ return strings.to_string(sb)
+}
+
+@(private)
+print_site :: proc(da: ^Allocator, label: string, s: Site) {
+ fmt.eprintfln(
+ " %s %s:%d proc=%s phase=%s tick=%d",
+ label,
+ display_path(da, s.loc.file_path),
+ s.loc.line,
+ s.loc.procedure,
+ s.phase,
+ s.seq,
+ )
+ when BACKTRACES {
+ print_frames(da, s.bt, 8)
+ }
+}
+
+@(private)
+Leak_Group :: struct {
+ loc: runtime.Source_Code_Location,
+ phase: string,
+ count: int,
+ bytes: int,
+ min_size: int,
+ max_size: int,
+ first_seq: u64,
+ example: trace.Capture_Const,
+ resizes: int,
+ last_resize: runtime.Source_Code_Location,
+}
+
+/*
+Print the exit report to stderr. Safe to call via defer after swapping
+context.allocator to this allocator. Verifies every block first, so corruption that
+happened after the last free or phase change is still caught.
+*/
+report :: proc(
+ da: ^Allocator,
+ max_groups := 20,
+ max_sites := 15,
+ max_frames := 8,
+ loc := #caller_location,
+) {
+ context.allocator = da.internals
+ sync.guard(&da.mutex)
+ verify_all(da, "exit", loc)
+
+ leaks := len(da.live)
+ fmt.eprintln("== debug allocator report ==")
+ if len(da.issues) == 0 && leaks == 0 {
+ fmt.eprintln("verdict: clean (no issues, no leaks)")
+ } else {
+ fmt.eprintfln(
+ "verdict: %d issue(s), %d leaked block(s) totalling %d b",
+ len(da.issues),
+ leaks,
+ da.live_bytes,
+ )
+ }
+ fmt.eprintfln(
+ "allocs=%d frees=%d live=%d live_bytes=%d peak_bytes=%d quarantined=%d issues=%d",
+ da.total_allocs,
+ da.total_frees,
+ leaks,
+ da.live_bytes,
+ da.peak_bytes,
+ len(da.dead),
+ len(da.issues),
+ )
+ fmt.eprint("phases=")
+ for p, i in da.phases {
+ if i > 0 {
+ fmt.eprint(" > ")
+ }
+ fmt.eprint(p)
+ }
+ fmt.eprintln()
+
+ if len(da.issues) > 0 {
+ fmt.eprintfln(
+ "-- issues (%d, each printed in full where it was detected) --",
+ len(da.issues),
+ )
+ for iss in da.issues {
+ fmt.eprintf("#%d %s ptr=%p", iss.id, kind_name(iss.kind), iss.ptr)
+ if iss.has_alloc {
+ fmt.eprintf(
+ " alloc %s:%d",
+ display_path(da, iss.alloc.loc.file_path),
+ iss.alloc.loc.line,
+ )
+ }
+ if iss.has_first_free {
+ fmt.eprintf(
+ " %s %s:%d",
+ "resize" if iss.by_resize else "free",
+ display_path(da, iss.first_free.loc.file_path),
+ iss.first_free.loc.line,
+ )
+ }
+ fmt.eprintfln(
+ " detected %s:%d (%s)",
+ display_path(da, iss.op.loc.file_path),
+ iss.op.loc.line,
+ iss.stage,
+ )
+ }
+ }
+
+ if leaks > 0 {
+ print_leaks(da, max_groups, max_frames)
+ }
+ print_sites(da, max_sites)
+}
+
+@(private)
+print_leaks :: proc(da: ^Allocator, max_groups, max_frames: int) {
+ groups := make(map[Site_Key]Leak_Group, da.internals)
+ defer delete(groups)
+ for _, l in da.live {
+ key := Site_Key {
+ file = l.alloc.loc.file_path,
+ line = l.alloc.loc.line,
+ }
+ g, exists := &groups[key]
+ if !exists {
+ groups[key] = Leak_Group {
+ loc = l.alloc.loc,
+ phase = l.alloc.phase,
+ min_size = l.size,
+ max_size = l.size,
+ first_seq = l.alloc.seq,
+ example = l.alloc.bt,
+ }
+ g = &groups[key]
+ }
+ g.count += 1
+ g.bytes += l.size
+ g.min_size = min(g.min_size, l.size)
+ g.max_size = max(g.max_size, l.size)
+ g.resizes += l.resizes
+ if l.resizes > 0 {
+ g.last_resize = l.last_resize
+ }
+ if l.alloc.seq < g.first_seq {
+ g.first_seq = l.alloc.seq
+ g.example = l.alloc.bt
+ g.phase = l.alloc.phase
+ }
+ }
+
+ list := make([dynamic]Leak_Group, 0, len(groups), da.internals)
+ defer delete(list)
+ for _, g in groups {
+ append(&list, g)
+ }
+ slice.sort_by(list[:], proc(a, b: Leak_Group) -> bool {
+ return a.bytes > b.bytes
+ })
+
+ fmt.eprintfln("-- leaks (%d group(s), %d block(s)) --", len(list), len(da.live))
+ for g, i in list {
+ if i >= max_groups {
+ fmt.eprintfln(
+ "... %d more leak groups omitted (raise max_groups to see them)",
+ len(list) - i,
+ )
+ break
+ }
+ if g.min_size == g.max_size {
+ fmt.eprintf("LEAK %d x %d b = %d b", g.count, g.min_size, g.bytes)
+ } else {
+ fmt.eprintf(
+ "LEAK %d block(s), %d b total (sizes %d..%d b)",
+ g.count,
+ g.bytes,
+ g.min_size,
+ g.max_size,
+ )
+ }
+ fmt.eprintf(
+ " phase=%s site=%s:%d proc=%s first_tick=%d",
+ g.phase,
+ display_path(da, g.loc.file_path),
+ g.loc.line,
+ g.loc.procedure,
+ g.first_seq,
+ )
+ if g.resizes > 0 {
+ fmt.eprintf(
+ " resized=%d (last at %s:%d)",
+ g.resizes,
+ display_path(da, g.last_resize.file_path),
+ g.last_resize.line,
+ )
+ }
+ fmt.eprintln()
+ when BACKTRACES {
+ print_frames(da, g.example, max_frames)
+ }
+ }
+}
+
+@(private)
+print_sites :: proc(da: ^Allocator, max_sites: int) {
+ list := make([dynamic]Site_Stats, 0, len(da.sites), da.internals)
+ defer delete(list)
+ for _, st in da.sites {
+ if st.allocs == 0 && st.resizes == 0 {
+ continue
+ }
+ append(&list, st)
+ }
+ if len(list) == 0 {
+ return
+ }
+ slice.sort_by(list[:], proc(a, b: Site_Stats) -> bool {
+ return a.bytes > b.bytes
+ })
+ fmt.eprintfln(
+ "-- lifetimes by allocation site (top %d of %d by bytes; lifetime in allocation ticks) --",
+ min(max_sites, len(list)),
+ len(list),
+ )
+ for st, i in list {
+ if i >= max_sites {
+ break
+ }
+ fmt.eprintf(
+ "SITE %s:%d proc=%s allocs=%d frees=%d live=%d bytes=%d peak_live=%d",
+ display_path(da, st.loc.file_path),
+ st.loc.line,
+ st.loc.procedure,
+ st.allocs,
+ st.frees,
+ st.live,
+ st.bytes,
+ st.peak_live_bytes,
+ )
+ if st.resizes > 0 {
+ fmt.eprintf(" resizes=%d", st.resizes)
+ }
+ if st.frees > 0 {
+ fmt.eprintf(
+ " lifetime=%d..%d avg %d",
+ st.lifetime_min,
+ st.lifetime_max,
+ st.lifetime_sum / u64(st.frees),
+ )
+ if st.cross_phase > 0 {
+ fmt.eprintf(" cross_phase=%d (last died in %s)", st.cross_phase, st.last_death)
+ }
+ }
+ fmt.eprintln()
+ }
+}
+
+// ---- paths and frames -----------------------------------------------------------
+
+// Directory of this source file, used to drop this package's own frames.
+@(private)
+SELF_DIR :: #directory
+
+// Frames from Odin's runtime are plumbing between the user's call and this allocator.
+@(private)
+RUNTIME_DIR :: ODIN_ROOT + "base"
+
+@(private)
+print_frames :: proc(da: ^Allocator, bt: trace.Capture_Const, max_frames: int) {
+ if bt.len == 0 {
+ return
+ }
+ locs, err := trace.resolve(bt, da.internals, context.temp_allocator)
+ if err != nil {
+ fmt.eprintfln(
+ " (call chain unavailable: %s; build with -debug)",
+ trace.resolve_err_string(err),
+ )
+ return
+ }
+ defer trace.locations_destroy(locs, da.internals)
+
+ self_dir := normalize(SELF_DIR, context.temp_allocator)
+ runtime_dir := normalize(RUNTIME_DIR, context.temp_allocator)
+ odin_root := normalize(ODIN_ROOT, context.temp_allocator)
+
+ shown := 0
+ for l in locs {
+ // Frames with no line number are OS or CRT entry code; nothing useful follows them.
+ if l.line == 0 {
+ break
+ }
+ path := normalize(l.file_path, context.temp_allocator)
+ if strings.has_prefix(path, self_dir) || strings.has_prefix(path, runtime_dir) {
+ continue
+ }
+ // The trace stops at the first frame outside the project and outside Odin's own tree: the
+ // frames past it have been CRT or OS startup code in every trace seen so far.
+ if !strings.has_prefix(path, da.root) && !strings.has_prefix(path, odin_root) {
+ break
+ }
+ if shown >= max_frames {
+ fmt.eprintln(" <- ...")
+ break
+ }
+ fmt.eprintfln(
+ " <- %s %s:%d",
+ short_proc(l.procedure),
+ display_path(da, l.file_path),
+ l.line,
+ )
+ shown += 1
+ }
+}
+
+// Polymorphic procs symbolize with their full signature appended; keep only the name.
+@(private)
+short_proc :: proc(p: string) -> string {
+ if i := strings.index_byte(p, ':'); i >= 0 && i + 1 < len(p) && p[i + 1] == 'p' {
+ return p[:i]
+ }
+ return p
+}
+
+@(private)
+display_path :: proc(da: ^Allocator, path: string) -> string {
+ n := normalize(path, context.temp_allocator)
+ if da.root != "" && strings.has_prefix(n, da.root) {
+ rest := n[len(da.root):]
+ if len(rest) > 0 && rest[0] == '/' {
+ rest = rest[1:]
+ }
+ return rest
+ }
+ return n
+}
+
+/*
+Forward slashes so paths from different sources compare equal, and on Windows lowercase
+as well, because there the same file reaches us spelled both ways. Not elsewhere: on a
+case-sensitive filesystem lowercasing invents a path that does not exist, and the point
+of this output is that a reader can open what it names.
+
+The result is always owned by allocator, which is not free to arrange. strings.replace_all
+hands back the input untouched when there is nothing to replace — which on a path with no
+backslashes is every time — and the caller's input can be a slice of a compile-time
+literal, since os.dir returns one. Owning it here is what lets destroy free da.root
+instead of calling free on rodata.
+*/
+@(private)
+normalize :: proc(path: string, allocator: mem.Allocator) -> string {
+ fwd, replaced := strings.replace_all(path, "\\", "/", allocator)
+ when ODIN_OS == .Windows {
+ lowered := strings.to_lower(fwd, allocator)
+ if replaced {
+ delete(fwd, allocator)
+ }
+ return lowered
+ } else {
+ return replaced ? fwd : strings.clone(path, allocator)
+ }
+}