Skip to content

Memory oracle has blind spots: freelists, arena, NaN-boxed slots and JIT code are invisible to ASan #1665

Description

@InauguralPhysicist

Owner observation (2026-10-07, after #1661): "our use after free/heap after use is hybrid. so I'm not even sure how oracled it is."

He is right. EigenScript's memory is a hybrid of refcounting, a cycle collector, a bump arena, per-thread Value/Env freelists, and NaN-boxed slots, and our memory oracles see only the system-allocator boundary. #1661 was caught by ASan only by luck: the stale pointer happened to point at a list that had really been free()d. Had the stale slot pointed at a recycled freelist object or an arena slice, ASan would have stayed silent.

Blind spots, measured from the code

  1. Freelists: a recycled Value/Env stays "allocated" to ASan. Only its REFCOUNT field is poisoned, and only in the EIGS_VALGRIND build (eigenscript.c:14-30). A parked env's values[] slots are not poisoned at all, and that is the field Heap use-after-free: under-arity call leaves an unbound param slot that a GC cull frees (v0.45.0, minimal repro) #1661 read.
  2. The arena: ASan and Valgrind see one big defined block, so an arena slice used after reset is invisible (memory: feedback_memtool_arena_blindspot).
  3. JIT-emitted code is not instrumented. Its GET_LOCAL reads values[slot] unchecked, where the interpreter bounds-checks. Stale reads therefore land only in JIT tiers (Heap use-after-free: under-arity call leaves an unbound param slot that a GC cull frees (v0.45.0, minimal repro) #1661 needed the JIT to crash).
  4. NaN-boxed slots: a stale immediate in a reused slot reads as a valid value. No tool can flag that by construction.

Done when (each with a planted fault that the new mode catches and today's ASan misses)

  • An oracle build mode (for example make asan-strict, flag EIGS_POOL_OFF) that bypasses the freelists and the arena, so every Value, Env and string is a real malloc/free. ASan then sees every object's lifetime. Run the suite under it in CI (sharded like the ASan lane).
  • ASan manual poisoning (ASAN_POISON_MEMORY_REGION) of WHOLE parked freelist objects, including an env's values[], and of arena slices on reset, for the normal ASan build. Unpoison on reuse.
  • A trap tag: under ASan and debug builds, recycled slots are filled with a reserved NaN-box "poisoned" tag, and any read of that tag aborts with the read site. This catches stale-immediate reads that no sanitizer can see.
  • A JIT debug mode: an EIGS_JIT_CHECKED build flag where emitted GET_LOCAL and SET_LOCAL bounds- and binding-check like the interpreter. CI runs the JIT-tier suite under it.
  • A re-run of Heap use-after-free: under-arity call leaves an unbound param slot that a GC cull frees (v0.45.0, minimal repro) #1661 against each new mode, with the fix reverted. Each mode must flag it without needing the lucky really-freed pointer. Variants: the stale slot points at a freelist object, an arena slice, or an immediate.
  • CFLite fuzzing (already planned for the pre-v1 security gate) runs against the oracle build, not the pooled one.

Related: #1661 (the bug that exposed this), and the pre-v1 security gate.

Activity

  1. InauguralPhysicist commented on Oct 7, 2026

    @InauguralPhysicist
    CollaboratorAuthor

    History from the owner (2026-10-07): the hybrid allocator was not a deliberate design choice. An older model implemented it to stop iLambdaAi from freezing the dev box (memory growth). Per our rule that inherited design is a hypothesis, the question comes first: should the pools exist at all?

    New first checklist item (it comes before the poisoning work):

    • With the pool-off build from this issue, measure pooled against pool-off on iLambdaAi's real workload (peak RSS: the freeze was a peak-memory cliff), DMG, the suite and the CodSpeed targets. Report peak RSS, wall time and instruction counts, as paired runs.
    • Check whether pool-off freezes iLambdaAi on today's runtime at all. If it does not, the original cause is gone or was misattributed (unbounded growth or retention, with pooling only shrinking it).
    • Decide on the numbers:
      • if pooling pays little, use plain malloc/free, the standard choice, and most blind spots disappear by construction;
      • if it pays a lot, keep it fully poisoned and checked (the modes below).
  2. InauguralPhysicist commented on Oct 7, 2026

    @InauguralPhysicist
    CollaboratorAuthor

    Archaeology results (original history at InauguralPhysicist/EigenScript, iLambdaAi, and the surviving Python prototype v0.4.1). This corrects the framing above: only the arena was the iLambdaAi patch.

    piece introduced stated reason isolated measurement? verdict
    bump arena iLambdaAi@4d465c2 (2026-03-25) training OOM on the 3.8 GB laptop: the first C runtime (Replit Agent, iLambdaAi@a5b62a3) freed nothing during eval "40GB reclaimed, memory flat" (against no reclamation at all) redundant since refcounting (aafcd0e, 2026-04-16); still produces escape bugs (#873, #1184, #1588). Remove it, or move it to a package.
    Value freelist fca1b6f (2026-04-28) DMG growing 45 MB/s none isolated: the same commit fixed real refcount bugs, which is what bounded the growth pool-off A/B
    env freelist c478bdd (2026-05-14) no rationale (empty commit body) none pool-off A/B
    call-env recycling 8946bae (2026-06-10) DMG per-call cost microbench -17 to -26%; DMG itself ~1.7% the code path behind #1661. Drop it if <2% on DMG, EMS and Tidepool; otherwise poison it.

    The standard baseline (refcounting plus a cycle collector over plain malloc) is what the original Python prototype had, and what we have today underneath the pools. One EIGS_POOL_OFF build settles all four rows. It is the first checklist item above.

  3. InauguralPhysicist commented on Oct 7, 2026

    @InauguralPhysicist
    CollaboratorAuthor

    Tape impact (owner, 2026-10-07): the deterministic trace tape came much later in the project, but it is now a core product (replay contract, profiler, cross-device bridge). Pre-v1 decisions that change what a tape record holds or means are decided as a SET and land in ONE tape-format bump: #1644 (value model), #1667 (observer signal), the device/stream id, and #1665 (a stale read is also a replay-determinism bug). The tape is the most irreversible pre-v1 commitment, because old tapes must keep replaying after the freeze.

  4. InauguralPhysicist commented on Oct 8, 2026

    @InauguralPhysicist
    CollaboratorAuthor

    Design note (owner discussion, 2026-10-07): the pools patch costs that other languages remove at the source.

    The pool-off measurement (#1665, branch pool-off-1665, results are being posted with its PR) shows the pools are a speed lever with no peak-memory benefit. Each layer patches a cost created by an earlier design choice:

    layer cost it patches how mature runtimes solve it
    Value freelist (NUM) numbers become heap Values on some paths immediate/unboxed numbers in the value word: Lua, LuaJIT, V8 small ints, SpiderMonkey/JavaScriptCore NaN-boxing. CPython's float freelist is the same patch we have.
    Env freelist + call-env recycling every call heap-allocates an environment locals in registers on a contiguous VM stack; only captured variables go to the heap (Lua upvalues, JS context allocation, Scheme closure conversion)
    bump arena (mark/reset) the first C runtime freed nothing (the iLambdaAi freeze) already fixed by refcounting (aafcd0e). Generational nurseries (Java, .NET, OCaml) are the general solution.

    The overall shape (refcount + cycle collector + freelists) is CPython's model, inherited, not chosen (#1670 trust boundary).

    Root-cause candidates, for the value-model release (#1644):

    1. Immediate numbers everywhere. This removes the Value freelist.
    2. Stack-slot frames with upvalues for captured variables. This removes both env layers AND the Heap use-after-free: under-arity call leaves an unbound param slot that a GC cull frees (v0.45.0, minimal repro) #1661/JIT mis-resolves an in-scope loop variable under EIGS_POOL_OFF (stale env read, #1661 family, invisible to ASan) #1674 class: with no recycled env slots, there is nothing to read stale.

    Keep or decide, not copy:

    • Refcounting stays a deliberate candidate. Its deterministic reclamation points suit deterministic replay (principle 0) better than tracing-GC timing.
    • The observer is the EigenScript-specific question. Observer state is keyed by binding identity (Env::obs), so stack-slot frames must say where per-variable observer data lives. Measure the options; don't assume one.
    • The EigenLua rung (a register-VM reference) is the oracle for frame-layout cost.

    The per-layer toggle measurement (four sub-flags instead of one EIGS_POOL_OFF) is still useful to price each patch. The decision this feeds is whether to remove the causes, not whether to tune the patches.

  5. InauguralPhysicist commented on Oct 8, 2026

    @InauguralPhysicist
    CollaboratorAuthor

    Item 1 (pool-off measurement) is up as #1675, with the measurement table and oracle findings in its body. Headline: the pools buy speed only (DMG dispatch is +120% Ir with pools off, with no change in peak RSS). The iLambdaAi arena workload does not OOM without the pools. Pool-off is a negative control for the #1661 class, not a detector, so items 2 and 4 are the real oracle. New bug found: #1674.

  6. InauguralPhysicist commented on Oct 8, 2026

    @InauguralPhysicist
    CollaboratorAuthor

    Pre-registered decision rule (2026-10-07). Owner: "I don't have a decision, that should be a measurement that decides." This rule is fixed BEFORE the numbers exist. Whatever the matrix says is the outcome.

    Arms

    arm build
    A pooled (today)
    B all pools off (EIGS_POOL_OFF, #1675)
    C1–C4 ONE layer off at a time: Value freelist / Env freelist / call-env recycling / arena
    D root-cause prototype: immediate NUMs + stack-slot locals for non-captured variables (upvalues for captured), with pools off. Must pass the full suite and observer_corpus unchanged.

    Metrics

    Rules

    1. A layer whose solo removal (C) costs < 2% Ir on every workload is removed.* Simpler and standard wins ties.
    2. D replaces the env layers if: D's DMG Ir is within 5% of A, D is no worse than B on any workload, and the Heap use-after-free: under-arity call leaves an unbound param slot that a GC cull frees (v0.45.0, minimal repro) #1661/JIT mis-resolves an in-scope loop variable under EIGS_POOL_OFF (stale env read, #1661 family, invisible to ASan) #1674 class is impossible by construction in D.
    3. Otherwise the surviving layers stay, and Memory oracle has blind spots: freelists, arena, NaN-boxed slots and JIT code are invisible to ASan #1665 items 2 and 4 (parked-slot poisoning, EIGS_JIT_CHECKED) are mandatory before v1. Required regardless for whatever pooling survives.
    4. Observer: D must say where per-variable observer state lives. Its observer overhead (observed_loop vs unobserved_loop delta) may not exceed A's by more than 2%.

    Order

    C1–C4 first (cheap: four sub-flags under the #1675 flag). D second (a prototype branch, not a merge candidate until the rule says so).

  7. InauguralPhysicist commented on Oct 8, 2026

    @InauguralPhysicist
    CollaboratorAuthor

    Arms C1–C4 results (branch pool-layers-1665, commit 4d05654). Four sub-flags. The default build and make pool-off are byte-identical to before (29/29 .o files, checked with cmp).

    layer max solo ΔIr over the 10 workloads DMG wall (median of 3) own-workload check rule 1
    C1 Value NUM freelist +12.41% (alloc_heavy) +1.4% n/a KEEP
    C2 Env freelist +1.10% noise n/a REMOVE, blocked on #1674 (see below)
    C3 call-env recycling +13.79% (bench_dmg_shape) +13.5% n/a KEEP
    C4 bump arena +0.00% noise iLambdaAi token_train: +57.5% wall, +1.9 MB RSS, no OOM KEEP (see below)

    How rule 1 was applied to the arena. None of the 10 Ir workloads touches the arena, so its +0.00% means it was never exercised there, not that it costs nothing. The rule's metric list includes token_train, which is the arena's only consumer. On that workload removing the arena costs +57.5% wall, far above the 2% threshold. Verdict: KEEP. Its original purpose (preventing the iLambdaAi freeze) is gone, and today it is purely a speed lever. To close the gap in the procedure, every future arm must be judged on at least one workload that exercises its layer. A short cachegrind run of token_train (A vs C4) will confirm this in Ir.

    Interaction. On 9 of the 10 workloads the solo deltas add up exactly to the all-off total. On bench_dmg_shape the effect is super-additive: the solo deltas sum to +14.2%, but all-off costs +120.4%. That synergy runs through call-env recycling, which stays, so removing C2 alone stays in the additive regime. The removal PR must still re-measure C2 together with any other change.

    #1674 is the env-freelist layer. It reproduces with only C2 off and nowhere else. So the env-freelist removal and the #1674 fix land together, fix first.

    Next: arm D, the root-cause prototype (immediate NUMs and stack-slot locals), against the same matrix.

  8. InauguralPhysicist commented on Oct 8, 2026

    @InauguralPhysicist
    CollaboratorAuthor

    Arena scope (2026-10-08). arena_mark / arena_reset are general builtins, but their doc comment says "call before a training step". The only working consumer across the ecosystem is iLambdaAi's legacy model-free token_train.eigs; the current transformer trainer uses the native model builtins and no arena. The other hits (EigenScript ×5, EigenGauntlet ×1, ouroboros ×1) test the builtin itself. Its costs are borne by the whole language: the no-yield-inside-arena refusals in the task layer, the per-value arena flag on hot paths, and the escape-bug class (#873, #1184, #1588). As a user-visible manual memory region in a refcounted language it is a principle-0 deviation, so it is not a language feature to keep. The measurement only decides whether its speed is recovered elsewhere. Arm D must include token_train (A vs D, wall and Ir). If D recovers the speed (immediate NUMs remove the per-number malloc/free), the arena is removed by rule. If not, the residual is measured and explained.

  9. InauguralPhysicist commented on Oct 8, 2026

    @InauguralPhysicist
    CollaboratorAuthor

    Correction to the arena verdict (2026-10-08). token_train.eigs is iLambdaAi's frozen reference baseline (README: "2-token trigram training (frozen reference baseline)"; still called by run_token_loop.sh). It is not an active trainer, and a fresh clone cannot run it today (iLambdaAi#49). A frozen baseline's requirement is that its results stay the same, not its speed. The C4 arm already showed that without the arena it runs to completion, bounded (21 MB), with no OOM and no freeze. So the +57% wall is not a cost rule 1 should weigh. Arena verdict: REMOVE, on the same evidence class as C2 (no exercised workload that needs its speed), with this acceptance check: token_train with the arena removed reproduces the pooled run's loss trajectory and checkpoint bytes on a fixed corpus (after iLambdaAi#49 gives it a committed corpus). Arm D no longer gates this removal. Removing it also deletes the task-layer no-yield-in-arena refusals and the per-value arena flag from the hot paths.

  10. InauguralPhysicist commented on Oct 8, 2026

    @InauguralPhysicist
    CollaboratorAuthor

    Correction to the correction. token_train is not iLambdaAi's current baseline either; the README label is stale. The baseline is the 4-gram on the slot corpus (held-out 1.766), which the transformer beats. So the arena serves a script that is neither the current trainer nor the current baseline, and cannot run from a fresh clone. Arena: REMOVE, with no equivalence gate. iLambdaAi#49 now retires the script. Removal order: iLambdaAi#49 lands first (no consumer calls the builtins), then the EigenScript removal of arena_mark/arena_reset, the arena allocator, the per-value arena flag and the task-layer no-yield-in-arena refusals, with their tests.

  11. added 2 commits that reference this issue on Oct 8, 2026
  12. InauguralPhysicist commented on Oct 8, 2026

    @InauguralPhysicist
    CollaboratorAuthor

    Env-freelist removal is unblocked. #1674 merged (#1689): the inline-cache ABA on env address reuse is fixed with a uint64 process-wide atomic version counter plus a high-water on free, so correctness no longer depends on the env freelist keeping envs at stable addresses. [0fe] runs test_bool_fuzz.sh with EIGS_ENV_FREELIST_OFF=1 on the default build, and make pool-off-env passes it. Next for this issue, per the rule: remove the env freelist (C2) and consolidate the runtime EIGS_ENV_FREELIST_OFF knob with the compile-time EIGS_POOL_OFF_ENV arm. The arena removal stays gated on iLambdaAi#49.

  13. 15 remaining items

  14. InauguralPhysicist commented on Oct 12, 2026

    @InauguralPhysicist
    CollaboratorAuthor

    Arm D step 2 landed (2026-10-11). List items and dict values are EigsSlot storage as of #1726. Numbers are stored immediate, with num_guard on every store.

    before after
    1M-element numeric list 103.9 B/element 8.0 B/element
    100k four-key numeric dicts 1095 B/dict 713 B/dict
    DMG cpu_instrs Ir (real emulator) −1.2% (JIT tier) / −1.3% (VM tier)
    bench_perf Ir −13.5%

    Rule 2 (DMG within 5%) is met with margin.

    Path:

    Every batch was gated locally (release suite, full ASan with leak tally 0, rehearsal suite, Ir, tape identity, jit_diff), and three blind reviews ran along the way. They caught:

    • the get_at use-after-free and the dispatch dead-frame return (B3);
    • the ragged-tensor SIGSEGV (B5);
    • the raw-storage gate gap (B10);
    • a sourced set -e in a section fragment that silently killed the macOS merge-queue suite. It is fixed, and suite_label_check.sh now rejects errexit/nounset toggles in fragments.

    Remaining on this issue:

    • B11: number producers that still box a value only to store it;
    • the NaN-box trap tag;
    • stack-slot locals (arm D phase 2).

    Follow-ups filed along the way: #1709, #1710, #1711, #1719, #1722, #1723, #1725.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:memorySubsystem: memorykind:gate-defectA gate, check or claim that measures less than it says

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions