commit 9f8e834ac504d28c329b7ffe1d98a9a416b965ce
parent babaf2f99e3fc420d2eac85fb37ace10d1455a48
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Wed, 16 Sep 2026 21:37:55 -0400
debug: replace lifetime allocator with a bug-finding debug allocator
The lifetime allocator only listed leaks. An agent reading its output could
not see heap overflows, double frees, writes through pointers kept across an
append, or deletes with the wrong length, which are the allocation bugs that
actually cost time. The debug allocator guards every block, quarantines and
poisons freed memory, verifies everything at each free, phase change, and
exit, and prints each issue where it is detected with the allocating site,
the freeing site, the damage, and what it usually means. Resizes always move
the block so stale pointers surface immediately.
Diffstat:
4 files changed, 957 insertions(+), 344 deletions(-)
diff --git a/Makefile b/Makefile
@@ -15,7 +15,7 @@ BUILD := build
FLAGS := -vet -strict-style
EXE := $(if $(filter Windows_NT,$(OS)),.exe,)
-SRC := $(wildcard *.odin) $(wildcard ntfs/*.odin) $(wildcard lifetime/*.odin)
+SRC := $(wildcard *.odin) $(wildcard ntfs/*.odin) $(wildcard debug/*.odin)
.PHONY: all debug release test check clean
diff --git a/debug/debug.odin b/debug/debug.odin
@@ -0,0 +1,950 @@
+/*
+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.
+
+Usage (debug builds only, compile with -debug so call chains symbolize):
+
+ import "core:debug/trace"
+ import "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 "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)
+
+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 :: 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 :: 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,
+ dead: map[rawptr]Dead,
+ 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)
+}
+
+// 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)
+ }
+ 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, 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)
+ }
+ l = Live{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, ptr: rawptr, loc: runtime.Source_Code_Location, by_resize: bool) {
+ mem.set(ptr, PATTERN_DEAD, l.size)
+ 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{
+ 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
+}
+
+@(private)
+backing_free :: proc(da: ^Allocator, raw: rawptr, total: int) {
+ da.backing.procedure(da.backing.data, .Free, 0, 0, raw, total)
+}
+
+// ---- 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, 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,
+ })
+ mem.set(l.raw, PATTERN_GUARD, l.front) // 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,
+ })
+ mem.set(raw_data(block[l.front + l.size:]), PATTERN_GUARD, GUARD)
+ }
+}
+
+@(private)
+verify_poison :: proc(da: ^Allocator, d: Dead, 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,
+ })
+ mem.set(ptr, PATTERN_DEAD, d.size)
+ }
+}
+
+// Find bytes that differ from `pattern`. Returns the first offset, how many differ,
+// and up to eight of the offending values.
+@(private)
+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")
+ }
+}
+
+// ---- 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
+ }
+ // Anything outside the project and outside Odin's own tree is CRT or OS startup code.
+ 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
+}
+
+// Lowercase with forward slashes so Windows paths from different sources compare equal.
+@(private)
+normalize :: proc(path: string, allocator: mem.Allocator) -> string {
+ fwd, _ := strings.replace_all(path, "\\", "/", allocator)
+ return strings.to_lower(fwd, allocator)
+}
diff --git a/lifetime/lifetime.odin b/lifetime/lifetime.odin
@@ -1,337 +0,0 @@
-/*
-Lifetime allocator: a debug-build allocator whose report is written for a reader
-(human or LLM) who has never seen the program before.
-
-It is a thin layer over `core:debug/trace.Tracking_Allocator`, which already does
-the hard parts (leak map, bad-free detection, stats, backtrace capture). This layer
-adds what the core allocator lacks:
-
- 1. Phases. Call `lifetime.set_phase(&la, "scan")` at the boundaries of your
- program's stages. Every allocation remembers the phase it was born in, so a
- leak reads as "allocated during 'scan', still live at exit" instead of a bare
- address.
- 2. A grouped report. Ten thousand leaks from one `make` call become one line:
- count, total bytes, phase, site, and a single representative call chain.
- Groups are sorted by total bytes so the biggest problem is on top.
- 3. Noise removal. Frames inside this package and inside Odin's `base/runtime`
- are dropped, the chain stops at the first frame outside the project and the
- Odin tree (CRT and OS startup code), and paths are shown relative to the
- directory of the file that called `init`. Code that lives outside the project
- directory and outside the Odin root is therefore not shown; keep third-party
- packages under the project.
-
-Usage (debug builds only, compile with `-debug` so backtraces symbolize):
-
- import "core:debug/trace"
- import "lifetime"
-
- main :: proc() {
- when ODIN_DEBUG {
- la: lifetime.Allocator
- lifetime.init(&la, context.allocator)
- defer lifetime.destroy(&la)
- defer lifetime.report(&la)
- context.allocator = lifetime.allocator(&la)
- context.assertion_failure_proc = trace.assertion_failure_proc
- }
- run()
- }
-
-Phase names must outlive the allocator. String literals are the intended input.
-*/
-package lifetime
-
-import "base:runtime"
-import "core:debug/trace"
-import "core:fmt"
-import "core:mem"
-import "core:path/filepath"
-import "core:slice"
-import "core:strings"
-import "core:sync"
-
-Allocator :: struct {
- track: trace.Tracking_Allocator,
- // pointer -> phase name active when the allocation was made
- phases: map[rawptr]string,
- // pointer -> monotonically increasing allocation sequence number
- seqs: map[rawptr]u64,
- seq: u64,
- phase: string,
- mutex: sync.Mutex,
- // allocator used for this struct's own bookkeeping; never the tracked one
- internals: mem.Allocator,
- // directory of the file that called init; paths under it print relative
- root: string,
-}
-
-init :: proc(la: ^Allocator, backing: mem.Allocator, internals := context.allocator, loc := #caller_location) {
- trace.tracking_allocator_init(&la.track, backing, internals)
- // Record bad frees instead of panicking so the report can show all of them at once.
- la.track.bad_free_callback = trace.tracking_allocator_bad_free_callback_add_to_array
- la.phases.allocator = internals
- la.seqs.allocator = internals
- la.phase = "startup"
- la.internals = internals
- la.root = normalize(filepath.dir(loc.file_path), internals)
-}
-
-destroy :: proc(la: ^Allocator) {
- trace.tracking_allocator_destroy(&la.track)
- delete(la.phases)
- delete(la.seqs)
- delete(la.root, la.internals)
- la.phases = {}
- la.seqs = {}
- la.root = ""
-}
-
-allocator :: proc(la: ^Allocator) -> mem.Allocator {
- return mem.Allocator{procedure = allocator_proc, data = la}
-}
-
-// Mark the start of a program stage. Pass a string literal.
-set_phase :: proc(la: ^Allocator, name: string) {
- sync.guard(&la.mutex)
- la.phase = name
-}
-
-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) {
- la := (^Allocator)(data)
- result, err = trace.tracking_allocator_proc(&la.track, mode, size, alignment, old_memory, old_size, loc)
-
- sync.guard(&la.mutex)
- #partial switch mode {
- case .Alloc, .Alloc_Non_Zeroed:
- if err == nil && result != nil {
- la.seq += 1
- p := rawptr(raw_data(result))
- la.phases[p] = la.phase
- la.seqs[p] = la.seq
- }
- case .Free:
- delete_key(&la.phases, old_memory)
- delete_key(&la.seqs, old_memory)
- case .Free_All:
- clear(&la.phases)
- clear(&la.seqs)
- case .Resize, .Resize_Non_Zeroed:
- if err == nil {
- // A resized block keeps the identity of its original allocation.
- phase, had := la.phases[old_memory]
- seq := la.seqs[old_memory]
- if !had {
- phase = la.phase
- la.seq += 1
- seq = la.seq
- }
- delete_key(&la.phases, old_memory)
- delete_key(&la.seqs, old_memory)
- if result != nil {
- p := rawptr(raw_data(result))
- la.phases[p] = phase
- la.seqs[p] = seq
- }
- }
- }
- return
-}
-
-@(private)
-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,
-}
-
-/*
-Print the report to stderr. Safe to call via `defer` after swapping `context.allocator`
-to this allocator: the report switches back to the internals allocator so it never
-recurses into itself.
-
-Report shape:
-
- == lifetime report ==
- allocs=111 frees=5 live=106 live_bytes=3544 peak_bytes=3568 bad_frees=1
- LEAK 101 allocs, 3424 b total (sizes 24..1024 b) phase=scan site=main.odin:29 proc=main first_seq=2
- <- main::main main.odin:29
- LEAK 5 x 24 b = 120 b phase=scan site=main.odin:23 proc=main first_seq=1
- <- main::main main.odin:23
- BAD_FREE ptr=0x220FC50F648 site=main.odin:35 proc=main
- <- main::main main.odin:35
-*/
-report :: proc(la: ^Allocator, max_groups := 20, max_frames := 8) {
- context.allocator = la.internals
- sync.guard(&la.mutex)
- t := &la.track
-
- fmt.eprintln("== lifetime report ==")
- fmt.eprintfln(
- "allocs=%d frees=%d live=%d live_bytes=%d peak_bytes=%d bad_frees=%d",
- t.total_allocation_count,
- t.total_free_count,
- len(t.allocation_map),
- t.current_memory_allocated,
- t.peak_memory_allocated,
- len(t.bad_free_array),
- )
- if len(t.allocation_map) == 0 && len(t.bad_free_array) == 0 {
- fmt.eprintln("ok: no leaks, no bad frees")
- return
- }
-
- groups := make(map[string]Group, la.internals)
- defer delete(groups)
-
- for ptr, entry in t.allocation_map {
- phase := la.phases[ptr] or_else "unknown"
- key := fmt.tprintf("%s:%d|%s", entry.location.file_path, entry.location.line, phase)
- g, exists := &groups[key]
- if !exists {
- groups[key] = Group{
- loc = entry.location,
- phase = phase,
- min_size = entry.size,
- max_size = entry.size,
- first_seq = la.seqs[ptr] or_else 0,
- example = entry.backtrace,
- }
- g = &groups[key]
- }
- g.count += 1
- g.bytes += entry.size
- g.min_size = min(g.min_size, entry.size)
- g.max_size = max(g.max_size, entry.size)
- seq := la.seqs[ptr] or_else 0
- if seq != 0 && (g.first_seq == 0 || seq < g.first_seq) {
- g.first_seq = seq
- g.example = entry.backtrace
- }
- }
-
- list := make([dynamic]Group, 0, len(groups), la.internals)
- defer delete(list)
- for _, g in groups {
- append(&list, g)
- }
- slice.sort_by(list[:], proc(a, b: Group) -> bool {
- return a.bytes > b.bytes
- })
-
- 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 allocs, %d b total (sizes %d..%d b)", g.count, g.bytes, g.min_size, g.max_size)
- }
- fmt.eprintfln(
- " phase=%s site=%s:%d proc=%s first_seq=%d",
- g.phase,
- display_path(la, g.loc.file_path),
- g.loc.line,
- g.loc.procedure,
- g.first_seq,
- )
- print_frames(la, g.example, max_frames)
- }
-
- for bf in t.bad_free_array {
- fmt.eprintfln(
- "BAD_FREE ptr=%p site=%s:%d proc=%s",
- bf.memory,
- display_path(la, bf.location.file_path),
- bf.location.line,
- bf.location.procedure,
- )
- print_frames(la, bf.backtrace, max_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(la: ^Allocator, bt: trace.Capture_Const, max_frames: int) {
- locs, err := trace.resolve(bt, la.internals, context.temp_allocator)
- if err != nil {
- fmt.eprintfln(" (backtrace unavailable: %s; build with -debug)", trace.resolve_err_string(err))
- return
- }
- defer trace.locations_destroy(locs, la.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
- }
- // Anything outside the project and outside Odin's own tree is CRT or OS startup code.
- if !strings.has_prefix(path, la.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(la, 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(la: ^Allocator, path: string) -> string {
- n := normalize(path, context.temp_allocator)
- if la.root != "" && strings.has_prefix(n, la.root) {
- rest := n[len(la.root):]
- if len(rest) > 0 && rest[0] == '/' {
- rest = rest[1:]
- }
- return rest
- }
- return n
-}
-
-// Lowercase with forward slashes so Windows paths from different sources compare equal.
-@(private)
-normalize :: proc(path: string, allocator: mem.Allocator) -> string {
- fwd, _ := strings.replace_all(path, "\\", "/", allocator)
- return strings.to_lower(fwd, allocator)
-}
diff --git a/main.odin b/main.odin
@@ -16,7 +16,7 @@ import "core:time"
// Only referenced inside `when ODIN_DEBUG`; @(require) keeps release builds from
// rejecting the import as unused.
-@(require) import "lifetime"
+@(require) import "debug"
import "ntfs"
TOP_N :: 20
@@ -30,11 +30,11 @@ debug_main :: proc() -> int {
// Assertion failures print a call chain in every build; symbols need -debug.
context.assertion_failure_proc = trace.assertion_failure_proc
when ODIN_DEBUG {
- la: lifetime.Allocator
- lifetime.init(&la, context.allocator)
- defer lifetime.destroy(&la)
- defer lifetime.report(&la)
- context.allocator = lifetime.allocator(&la)
+ da: debug.Allocator
+ debug.init(&da, context.allocator)
+ defer debug.destroy(&da)
+ defer debug.report(&da)
+ context.allocator = debug.allocator(&da)
}
return run()
}