From 8c3831acd2100bd4ad6a35702e790608c3d8586d Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 26 May 2026 22:46:56 +0300 Subject: [PATCH] test: M4.0 allocator-threading regression coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regression tests pinning down the silent-error surface in M4.0: ffi-objc-arc-00 — single sx-defined-class instance round-trips through a TrackingAllocator-wrapped GPA. Captures alloc/dealloc deltas around the lifecycle, verifies (+1, +1). Pre-M4.0 the +alloc IMP used libc malloc and -dealloc used libc free; tracker would have observed (+0, +0) and missed the leak silently. ffi-objc-arc-00b — three instances alloc'd and released. Catches bugs where: - the captured allocator becomes shared (one global slot vs per-instance); - alloc captures the wrong allocator on the 2nd+ instance; - dealloc reads garbage if state[0] is overwritten between instances. Both tests are macos-only (libobjc + NSObject must be present at runtime). Both wrap the lifecycle in `push Context.{ allocator = xx tracker }` so the threading path is exercised. Important authoring note: `print` inside the push-block also routes through tracker (string formatting allocs), polluting the leak delta. Tests capture before/after counts WITHOUT any prints between alloc and release, then verify the BALANCE — every alloc paired with a dealloc — rather than absolute counts. Discovered while writing 00: an initial naive "leak_count() == 0" assertion failed not because M4.0 was broken but because print's string allocs weren't freed at scope exit. 187/187 example tests pass. --- examples/ffi-objc-arc-00-allocator-thread.sx | 87 +++++++++++++++++++ examples/ffi-objc-arc-00b-multi-instance.sx | 74 ++++++++++++++++ .../ffi-objc-arc-00-allocator-thread.exit | 1 + .../ffi-objc-arc-00-allocator-thread.txt | 1 + .../ffi-objc-arc-00b-multi-instance.exit | 1 + .../ffi-objc-arc-00b-multi-instance.txt | 1 + 6 files changed, 165 insertions(+) create mode 100644 examples/ffi-objc-arc-00-allocator-thread.sx create mode 100644 examples/ffi-objc-arc-00b-multi-instance.sx create mode 100644 tests/expected/ffi-objc-arc-00-allocator-thread.exit create mode 100644 tests/expected/ffi-objc-arc-00-allocator-thread.txt create mode 100644 tests/expected/ffi-objc-arc-00b-multi-instance.exit create mode 100644 tests/expected/ffi-objc-arc-00b-multi-instance.txt diff --git a/examples/ffi-objc-arc-00-allocator-thread.sx b/examples/ffi-objc-arc-00-allocator-thread.sx new file mode 100644 index 0000000..4a6346a --- /dev/null +++ b/examples/ffi-objc-arc-00-allocator-thread.sx @@ -0,0 +1,87 @@ +// ffi-objc-arc-00 — M4.0 end-to-end allocator threading regression. +// +// Verifies that the per-instance allocator design from M1.2 A.5 + M4.0 +// is actually wired: +// 1. `push Context.{ allocator = xx tracker } { SxFoo.alloc(); }` → +// the state struct is allocated via tracker (not libc). +// 2. The state struct's first field is `__sx_allocator` (captures the +// tracker so -dealloc can free through it). +// 3. `f.release()` drives refcount → 0 → -dealloc fires → reads the +// captured allocator → calls tracker.dealloc(state). +// 4. The alloc/dealloc deltas around the call pair balance to (+1, +1) +// — exactly one sx-defined-class state struct round-trips. +// +// Pre-M4.0 the +alloc IMP used libc `malloc` and -dealloc used libc +// `free`, both bypassing context.allocator — tracker would have +// observed (+0, +0) deltas, missing the leak silently. +// +// Important: capture tracker counters BEFORE and AFTER the +// alloc/release with NOTHING else allocating in between. `print` +// allocates strings (NSString-backed), which would also route through +// tracker and pollute the deltas. + +#import "modules/std.sx"; +#import "modules/allocators.sx"; +#import "modules/std/objc.sx"; +#import "modules/compiler.sx"; + +SxAllocProbe :: #objc_class("SxAllocProbe") { + #extends NSObject; + counter: s32; + alloc :: () -> *SxAllocProbe; +} + +main :: () -> s32 { + inline if OS == .macos { + gpa := GPA.init(); + tracker := TrackingAllocator.init(xx gpa); + + push Context.{ allocator = xx tracker, data = null } { + // Snapshot BEFORE the sx-defined alloc. + alloc_before := tracker.alloc_count; + dealloc_before := tracker.dealloc_count; + + f := SxAllocProbe.alloc(); + + // Snapshot AFTER alloc, BEFORE release. + alloc_after_alloc := tracker.alloc_count; + dealloc_after_alloc := tracker.dealloc_count; + + f.release(); + + // Snapshot AFTER release. + alloc_after_release := tracker.alloc_count; + dealloc_after_release := tracker.dealloc_count; + + // Verify deltas (do all asserts before any print). + alloc_delta_a := alloc_after_alloc - alloc_before; + dealloc_delta_a := dealloc_after_alloc - dealloc_before; + alloc_delta_r := alloc_after_release - alloc_after_alloc; + dealloc_delta_r := dealloc_after_release - dealloc_after_alloc; + + if alloc_delta_a < 1 { + print("FAIL: alloc didn't fire through tracker; delta={}\n", alloc_delta_a); + return 1; + } + if dealloc_delta_a != 0 { + print("FAIL: dealloc fired prematurely; delta={}\n", dealloc_delta_a); + return 1; + } + if dealloc_delta_r < 1 { + print("FAIL: release→-dealloc didn't route through tracker; delta={}\n", dealloc_delta_r); + return 1; + } + if dealloc_delta_r != alloc_delta_a { + print("FAIL: alloc/dealloc deltas mismatched; alloc={} dealloc={}\n", + alloc_delta_a, dealloc_delta_r); + return 1; + } + } + + print("allocator round-trip: ok\n"); + } + inline if OS != .macos { + print("skipped (not macos)\n"); + } + 0; +} diff --git a/examples/ffi-objc-arc-00b-multi-instance.sx b/examples/ffi-objc-arc-00b-multi-instance.sx new file mode 100644 index 0000000..6f87d7a --- /dev/null +++ b/examples/ffi-objc-arc-00b-multi-instance.sx @@ -0,0 +1,74 @@ +// ffi-objc-arc-00b — multi-instance allocator threading. +// +// Verifies that EACH sx-defined-class instance captures its own +// allocator and round-trips through it. Catches bugs where: +// - the captured allocator is shared across instances (one global +// slot instead of per-instance). +// - alloc captures the wrong allocator on the 2nd+ instance. +// - dealloc reads garbage if state[0] is overwritten between +// instances. +// +// Three instances → three alloc events → three dealloc events. The +// tracker observes exactly +3 / +3 deltas. + +#import "modules/std.sx"; +#import "modules/allocators.sx"; +#import "modules/std/objc.sx"; +#import "modules/compiler.sx"; + +SxMultiProbe :: #objc_class("SxMultiProbe") { + #extends NSObject; + a: s32; + b: s32; + alloc :: () -> *SxMultiProbe; +} + +main :: () -> s32 { + inline if OS == .macos { + gpa := GPA.init(); + tracker := TrackingAllocator.init(xx gpa); + + push Context.{ allocator = xx tracker, data = null } { + alloc_before := tracker.alloc_count; + dealloc_before := tracker.dealloc_count; + + f1 := SxMultiProbe.alloc(); + f2 := SxMultiProbe.alloc(); + f3 := SxMultiProbe.alloc(); + + alloc_after_three := tracker.alloc_count - alloc_before; + + f1.release(); + f2.release(); + f3.release(); + + alloc_delta := tracker.alloc_count - alloc_before; + dealloc_delta := tracker.dealloc_count - dealloc_before; + + // alloc_delta MAY include extras from autorelease/etc. but + // each SxMultiProbe.alloc contributes at least 1. Check the + // BALANCE: every alloc paired with a dealloc. + if dealloc_delta != alloc_delta { + print("FAIL: alloc/dealloc unbalanced; alloc={} dealloc={}\n", + alloc_delta, dealloc_delta); + return 1; + } + if alloc_after_three < 3 { + print("FAIL: 3 SxMultiProbe.alloc()s should produce >= 3 tracker allocs; saw {}\n", + alloc_after_three); + return 1; + } + if dealloc_delta < 3 { + print("FAIL: 3 release()s should produce >= 3 tracker deallocs; saw {}\n", + dealloc_delta); + return 1; + } + } + + print("multi-instance round-trip: ok\n"); + } + inline if OS != .macos { + print("skipped (not macos)\n"); + } + 0; +} diff --git a/tests/expected/ffi-objc-arc-00-allocator-thread.exit b/tests/expected/ffi-objc-arc-00-allocator-thread.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/ffi-objc-arc-00-allocator-thread.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/ffi-objc-arc-00-allocator-thread.txt b/tests/expected/ffi-objc-arc-00-allocator-thread.txt new file mode 100644 index 0000000..41718ef --- /dev/null +++ b/tests/expected/ffi-objc-arc-00-allocator-thread.txt @@ -0,0 +1 @@ +allocator round-trip: ok diff --git a/tests/expected/ffi-objc-arc-00b-multi-instance.exit b/tests/expected/ffi-objc-arc-00b-multi-instance.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/ffi-objc-arc-00b-multi-instance.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/ffi-objc-arc-00b-multi-instance.txt b/tests/expected/ffi-objc-arc-00b-multi-instance.txt new file mode 100644 index 0000000..3378066 --- /dev/null +++ b/tests/expected/ffi-objc-arc-00b-multi-instance.txt @@ -0,0 +1 @@ +multi-instance round-trip: ok