// Resource-reclamation regression tests (release audit F3/F4/F5/F8). // // Live coding means recompiling the same programs over and over. These tests // lock in the invariant that recompiles RECLAIM (or reuse) every fixed-pool // resource they consume — state slots, data tables, graph nodes, and the // source arena — so that ordinary performance workflows can never exhaust a // pool and silently kill or revert an output: // // F3: anonymous stateful UGens must reuse state slots across recompiles // (structural identity via the StateResourceRegistry), and vector // literals must reuse data tables (content-interning in CellStore). // F4: on_cell_changed must gc unreachable nodes like every sibling // recompile path, or live cell edits exhaust the node pool. // F5: source-arena exhaustion must fail the eval with an explicit // diagnostic instead of installing a graph whose stored source text is // stale (which silently reverts the output on the next recompile). // F8: on_cell_changed must refresh the output's dependency list, or an // output stops reacting to cells introduced by a redefinition. #define CATCH_CONFIG_MAIN #include "../catch.hpp" #include "src/signal_engine/signal_engine.h" #include #include #include using namespace sig; namespace { struct ReclaimHarness { SignalEngine engine; ReclaimHarness() { engine.init_defaults(120.0, 4); } EvalResult eval(const std::string& code) { return eval_cold(code.c_str(), (uint32_t)code.size(), engine); } void eval_ok(const std::string& code) { EvalResult r = eval(code); INFO("code: " << code); if (r.kind == EvalResult::Error && r.diagnostic_count > 0) { INFO("diagnostic: " << (r.diagnostics[0].message ? r.diagnostics[0].message : "")); } REQUIRE(r.kind != EvalResult::Error); } // Execute one sample at t=0 and return the named output's value. double sample(const char* output_name) { double cell_values[MAX_CELLS]; engine.cells.snapshot_values(cell_values, MAX_CELLS); double hw_inputs[32] = {}; double outputs[MAX_OUTPUTS] = {}; double workspace[MAX_TOTAL_NODES] = {}; ExecutionContext ctx; ctx.t = 0.0; ctx.dt = 0.0; ctx.cell_values = cell_values; ctx.hw_inputs = hw_inputs; ctx.data_pool = engine.cells.data_pool; ctx.data_offsets = engine.cells.data_offsets; ctx.data_lengths = engine.cells.data_lengths; ctx.prev_outputs = engine.pool.prev_output_values; ctx.output_values = outputs; ctx.workspace = workspace; execute_all_outputs(engine.pool, ctx); SymbolID sym = internSymbol(output_name); uint16_t idx = GraphBuilder::resolve_output_index(sym); REQUIRE(idx != NODE_NONE); return outputs[idx]; } }; } // namespace // ============================================================================ // F3: anonymous stateful UGens reuse state slots across recompiles // ============================================================================ TEST_CASE("Reclaim: 100 re-evals of an anonymous lfo keep state slots bounded", "[reclaim][state_slots]") { ReclaimHarness h; h.eval_ok("(a1 (lfo 2))"); uint16_t slots_after_first = h.engine.pool.state_slot_count; REQUIRE(slots_after_first >= 1); for (int i = 0; i < 100; i++) { EvalResult r = h.eval("(a1 (lfo 2))"); INFO("iteration " << i); if (r.kind == EvalResult::Error && r.diagnostic_count > 0) { INFO("diagnostic: " << (r.diagnostics[0].message ? r.diagnostics[0].message : "")); } REQUIRE(r.kind != EvalResult::Error); } // Recompiling the identical program must resolve to the SAME slots via // the registry's structural key — not allocate a fresh slot per eval. REQUIRE(h.engine.pool.state_slot_count == slots_after_first); REQUIRE(h.engine.pool.outputs[0].valid); } TEST_CASE("Reclaim: anonymous state value survives re-eval of identical source", "[reclaim][state_slots]") { ReclaimHarness h; h.eval_ok("(a1 (phasor 1))"); REQUIRE(h.engine.pool.state_slot_count >= 1); // Simulate accumulated phase, then recompile the same program. h.engine.pool.state_values[0] = 0.625; h.eval_ok("(a1 (phasor 1))"); // Structural identity: the recompiled graph reads the same slot and the // accumulated value is preserved (init values are hints, not resets). REQUIRE(h.engine.pool.state_values[0] == Approx(0.625)); } TEST_CASE("Reclaim: distinct anonymous UGens across outputs get distinct slots", "[reclaim][state_slots]") { ReclaimHarness h; h.eval_ok("(a1 (phasor 1))"); uint16_t after_a1 = h.engine.pool.state_slot_count; h.eval_ok("(a2 (phasor 1))"); uint16_t after_a2 = h.engine.pool.state_slot_count; // Different output => different structural context => independent state. REQUIRE(after_a2 > after_a1); // But re-evaluating either output stays bounded. h.eval_ok("(a1 (phasor 1))"); h.eval_ok("(a2 (phasor 1))"); REQUIRE(h.engine.pool.state_slot_count == after_a2); } // ============================================================================ // F3 + F4: cell sweeps through on_cell_changed keep tables and nodes bounded // ============================================================================ TEST_CASE("Reclaim: 300-step cell sweep keeps data tables and nodes bounded", "[reclaim][tables][nodes]") { ReclaimHarness h; h.eval_ok("(define off 0)"); h.eval_ok("(a1 (+ off (step [1 2 3] beat)))"); uint8_t tables_after_first = h.engine.cells.data_table_count; uint16_t nodes_after_first = h.engine.pool.node_count; REQUIRE(tables_after_first >= 1); for (int i = 1; i <= 300; i++) { char buf[64]; snprintf(buf, sizeof(buf), "(define off %d)", i); h.eval_ok(buf); // Every dependent recompile must reuse the [1 2 3] table // (content-interning) instead of appending a duplicate. REQUIRE(h.engine.cells.data_table_count == tables_after_first); // And on_cell_changed must gc the orphaned old graph. The bound is // loose (the changed constant makes node counts wobble by a node or // two) but must not grow linearly with edits — pre-fix this reached // 434 nodes after 300 edits and 360 (the firmware cap) after ~237. REQUIRE(h.engine.pool.node_count <= nodes_after_first + 8); } // The output still compiles and tracks the swept cell. REQUIRE(h.engine.pool.outputs[0].valid); REQUIRE(h.sample("a1") == Approx(301.0)); // off=300 + step value 1 at t=0 } TEST_CASE("Reclaim: anonymous UGen output survives repeated dependency edits", "[reclaim][state_slots][nodes]") { ReclaimHarness h; // A stateful output that depends on a cell: every (define rate N) // triggers an on_cell_changed recompile of a graph with an anonymous lfo. h.eval_ok("(define rate 1)"); h.eval_ok("(a1 (lfo rate))"); uint16_t slots_after_first = h.engine.pool.state_slot_count; uint16_t nodes_after_first = h.engine.pool.node_count; for (int i = 2; i <= 100; i++) { char buf[64]; snprintf(buf, sizeof(buf), "(define rate %d)", i); h.eval_ok(buf); } REQUIRE(h.engine.pool.state_slot_count == slots_after_first); REQUIRE(h.engine.pool.node_count <= nodes_after_first + 8); REQUIRE(h.engine.pool.outputs[0].valid); } TEST_CASE("Reclaim: defstate replacement releases named and nested state resources", "[reclaim][defstate]") { ReclaimHarness h; for (int i = 0; i < 48; i++) { h.eval_ok("(defstate lifecycle-x 0 (+ lifecycle-x (integrate 1)))"); REQUIRE(h.engine.pool.state_slot_count >= 2); if (i == 0) h.eval_ok("(a1 lifecycle-x)"); char replacement[80]; if ((i & 1) == 0) { snprintf(replacement, sizeof(replacement), "(define lifecycle-x %d)", i + 10); } else { snprintf(replacement, sizeof(replacement), "(defs [lifecycle-x %d])", i + 10); } h.eval_ok(replacement); INFO("iteration " << i); REQUIRE(h.engine.pool.state_slot_count == 0); REQUIRE(h.engine.registry.entry_count == 0); REQUIRE(h.sample("a1") == Approx((double)i + 10.0)); } } TEST_CASE("Reclaim: state backing retained LKG graph survives failed reactive recompile", "[reclaim][defstate][lkg]") { ReclaimHarness h; h.eval_ok("(defstate retained-x 2 (+ retained-x 1))"); h.eval_ok("(a1 retained-x)"); REQUIRE(h.engine.pool.state_slot_count == 1); uint16_t old_root = h.engine.pool.outputs[0].root_node; h.eval_ok("(define retained-x (no-such-function 1))"); REQUIRE(h.engine.pool.outputs[0].root_node == old_root); REQUIRE(h.engine.pool.state_slot_count == 1); REQUIRE(h.engine.pool.state_update_roots[0] != NODE_NONE); REQUIRE(h.sample("a1") == Approx(2.0)); h.eval_ok("(a1 99)"); REQUIRE(h.engine.pool.state_slot_count == 0); REQUIRE(h.sample("a1") == Approx(99.0)); } TEST_CASE("Reclaim: state compaction remaps nested registry and live-edit owners", "[reclaim][defstate][live-edit]") { ReclaimHarness h; h.eval_ok("(defstate compact-a 0 (+ compact-a 1))"); h.eval_ok("(a1 compact-a)"); h.eval_ok( "(defstate compact-b 10 (+ compact-b " "(integrate (live-edit 1 :id \"compact-rate\" :min 0 :max 2))))"); h.eval_ok("(a2 compact-b)"); REQUIRE(h.engine.pool.state_slot_count == 3); REQUIRE(h.engine.registry.entry_count == 1); REQUIRE(h.engine.pool.live_slot_count == 1); h.eval_ok("(define compact-a 7)"); REQUIRE(h.engine.pool.state_slot_count == 2); REQUIRE(h.engine.registry.entry_count == 1); REQUIRE(h.engine.pool.live_slot_count == 1); REQUIRE(h.engine.cells.cells[internSymbol("compact-b")].data_table_id == 0); REQUIRE(h.engine.pool.state_owner_context[0] == MAX_OUTPUTS); REQUIRE(h.engine.pool.state_owner_context[1] == MAX_OUTPUTS); REQUIRE(h.engine.registry.entries[0].owner_context == MAX_OUTPUTS); REQUIRE(h.engine.pool.live_slots[0].owner_context == MAX_OUTPUTS); h.eval_ok( "(defstate compact-b 10 (+ compact-b " "(integrate (live-edit 1 :id \"compact-rate\" :min 0 :max 2))))"); REQUIRE(h.engine.pool.state_slot_count == 2); REQUIRE(h.engine.registry.entry_count == 1); REQUIRE(h.engine.pool.live_slot_count == 1); REQUIRE(h.sample("a1") == Approx(7.0)); REQUIRE(h.sample("a2") == Approx(10.0)); } TEST_CASE("Reclaim: nonnumeric vector define is rejected atomically", "[reclaim][vectors]") { ReclaimHarness h; h.eval_ok("(define lifecycle-v [7 8])"); SymbolID sym = internSymbol("lifecycle-v"); Cell before = h.engine.cells.cells[sym]; uint8_t tables_before = h.engine.cells.data_table_count; EvalResult rejected = h.eval("(define lifecycle-v [1 nope 2])"); REQUIRE(rejected.kind == EvalResult::Error); REQUIRE(rejected.diagnostic_count >= 1); REQUIRE(std::string(rejected.diagnostics[0].message).find("numeric") != std::string::npos); REQUIRE(h.engine.cells.data_table_count == tables_before); REQUIRE(h.engine.cells.cells[sym].kind == before.kind); REQUIRE(h.engine.cells.cells[sym].data_table_id == before.data_table_id); REQUIRE(h.engine.cells.cells[sym].value == Approx(before.value)); uint16_t length = 0; const double* values = h.engine.cells.get_data_table(before.data_table_id, length); REQUIRE(values != nullptr); REQUIRE(length == 2); REQUIRE(values[0] == Approx(7.0)); REQUIRE(values[1] == Approx(8.0)); } TEST_CASE("Reclaim: nonnumeric vector in defs preserves previous binding", "[reclaim][vectors][defs]") { ReclaimHarness h; h.eval_ok("(define lifecycle-v2 42)"); SymbolID sym = internSymbol("lifecycle-v2"); Cell before = h.engine.cells.cells[sym]; uint8_t tables_before = h.engine.cells.data_table_count; EvalResult rejected = h.eval("(defs [lifecycle-v2 [1 nope 2]])"); REQUIRE(rejected.kind == EvalResult::Error); REQUIRE(h.engine.cells.data_table_count == tables_before); REQUIRE(h.engine.cells.cells[sym].kind == before.kind); REQUIRE(h.engine.cells.cells[sym].value == Approx(42.0)); } TEST_CASE("Reclaim: hundreds of identical output re-evals reuse source arena", "[reclaim][arena]") { ReclaimHarness h; // Long enough that the old append-only behavior exhausts the desktop // arena during this loop (and exhausts the firmware-sized arena much // earlier), while still being a small, ordinary output expression. const std::string code = "(a1 (+ (sin beat) 123456789))"; h.eval_ok(code); const uint32_t head_after_first = h.engine.arena.write_head; REQUIRE(head_after_first > 0); for (int i = 0; i < 600; i++) { EvalResult r = h.eval(code); INFO("iteration " << i); if (r.kind == EvalResult::Error && r.diagnostic_count > 0) { INFO("diagnostic: " << (r.diagnostics[0].message ? r.diagnostics[0].message : "")); } REQUIRE(r.kind != EvalResult::Error); } REQUIRE(h.engine.arena.write_head == head_after_first); REQUIRE(h.engine.pool.outputs[0].valid); } TEST_CASE("Reclaim: legal longer replacements keep all source owners bounded", "[reclaim][arena][replacement]") { ReclaimHarness h; const std::string callable_short = "(define lifecycle-x (+ 1 2))"; const std::string callable_long = "(define lifecycle-x (+ 1 2 333333 444444))"; const std::string function_short = "(defn lifecycle-f [x] (+ x 1))"; const std::string function_long = "(defn lifecycle-f [x] (+ x 1 222222 333333))"; const std::string state_short = "(defstate lifecycle-s 0 (+ lifecycle-s 1))"; const std::string state_long = "(defstate lifecycle-s 0 (+ lifecycle-s 1 222222 333333))"; const std::string output_short = "(a1 (+ lifecycle-x (lifecycle-f 1)))"; const std::string output_long = "(a1 (+ lifecycle-x (lifecycle-f 1) 222222 333333))"; auto install = [&](bool longer) { h.eval_ok(longer ? callable_long : callable_short); h.eval_ok(longer ? function_long : function_short); h.eval_ok(longer ? state_long : state_short); h.eval_ok(longer ? output_long : output_short); }; install(false); const uint32_t short_live_bytes = h.engine.arena.write_head; install(true); const uint32_t long_live_bytes = h.engine.arena.write_head; REQUIRE(long_live_bytes > short_live_bytes); REQUIRE(long_live_bytes < SOURCE_ARENA_SIZE); // Cumulative replacement text exceeds the desktop arena many times and // the 4 KiB firmware arena much earlier. N, N+1, and continued reuse all // remain bounded by the four currently-published sources. const uint32_t replacements = static_cast(SOURCE_ARENA_SIZE / 8 + 1); for (uint32_t i = 0; i < replacements; i++) { INFO("replacement " << i); bool longer = (i & 1u) != 0; install(longer); REQUIRE(h.engine.arena.write_head == (longer ? long_live_bytes : short_live_bytes)); } install(false); // N+1 after historical bytes exceed the fixed store. REQUIRE(h.engine.arena.write_head == short_live_bytes); REQUIRE(h.engine.pool.outputs[0].valid); } TEST_CASE("Reclaim: callable retirement cannot alias a compacted live source", "[reclaim][arena][ownership]") { ReclaimHarness h; h.eval_ok("(define retired (+ 10 20 30))"); h.eval_ok("(a1 (+ 1000 2))"); const uint32_t with_callable = h.engine.arena.write_head; // Retiring the callable removes its metadata before compaction. A later // definition must append/recompact, not reuse the stale old offset and // overwrite a1's now-relocated source. h.eval_ok("(define retired 7)"); REQUIRE(h.engine.arena.write_head < with_callable); h.eval_ok("(define retired (+ 1 2 3 4 5))"); REQUIRE(h.sample("a1") == Approx(1002.0)); h.eval_ok("(define unrelated 9)"); REQUIRE(h.sample("a1") == Approx(1002.0)); } TEST_CASE("Reclaim: mixed owners remain readable after relocation and recompile", "[reclaim][arena][ownership][reactive][synth]") { ReclaimHarness h; h.eval_ok("(define retired-prefix (+ 10 20 30))"); h.eval_ok("(define reactive-source 1)"); h.eval_ok("(defstate mixed-state 0 (+ mixed-state 1))"); h.eval_ok("(a1 (+ reactive-source mixed-state))"); h.eval_ok( "(synth \"osc/sine\" :name \"mixed-synth\" " ":freq (+ reactive-source beat) :amp (+ 0.1 (* 0.01 bar)))"); const uint32_t before_retirement = h.engine.arena.write_head; h.eval_ok("(define retired-prefix 7)"); REQUIRE(h.engine.arena.write_head < before_retirement); auto require_live_region = [&](uint32_t offset, uint32_t length) { REQUIRE(length > 0); REQUIRE(offset < h.engine.arena.write_head); REQUIRE(length <= h.engine.arena.write_head - offset); REQUIRE(h.engine.arena.read(offset) != nullptr); }; SymbolID mixed_state = internSymbol("mixed-state"); REQUIRE(h.engine.cells.cells[mixed_state].flags == 0x02); uint16_t state_slot = h.engine.cells.cells[mixed_state].data_table_id; REQUIRE(state_slot < h.engine.pool.state_slot_count); const StateUpdateSource& state_source = h.engine.state_sources[state_slot]; REQUIRE(state_source.has_source); require_live_region(state_source.arena_offset, state_source.arena_length); REQUIRE(h.engine.output_sources[0].has_source); require_live_region(h.engine.output_sources[0].arena_offset, h.engine.output_sources[0].arena_length); REQUIRE(h.engine.synth_graph.control_count() == 2); for (uint16_t i = 0; i < h.engine.synth_graph.control_count(); i++) { const SynthControlChannel& control = h.engine.synth_graph.controls[i]; require_live_region(control.source_offset, control.source_length); REQUIRE(control.root_node != NODE_NONE); REQUIRE(control.root_node < h.engine.pool.node_count); } // Relocated output and synth-control source must still drive reactive // recompilation when their shared dependency changes. h.eval_ok("(define reactive-source 2)"); REQUIRE(h.engine.pool.outputs[0].valid); REQUIRE(h.sample("a1") == Approx(2.0)); REQUIRE(h.engine.synth_graph.control_count() == 2); for (uint16_t i = 0; i < h.engine.synth_graph.control_count(); i++) { const SynthControlChannel& control = h.engine.synth_graph.controls[i]; require_live_region(control.source_offset, control.source_length); REQUIRE(control.root_node != NODE_NONE); REQUIRE(control.root_node < h.engine.pool.node_count); } } TEST_CASE("Reclaim: do and scope compact between child transactions", "[reclaim][arena][do][scope][capacity]") { ReclaimHarness h; h.eval_ok("(define retired-in-do (+ 1 2 3 4))"); h.engine.arena.write_head = SOURCE_ARENA_SIZE - 2; h.eval_ok( "(do (define retired-in-do 7) " "(define published-in-do (+ 100 20 3)))"); REQUIRE(h.engine.cells.cells[internSymbol("retired-in-do")].kind == CellKind::Number); REQUIRE(h.engine.cells.cells[internSymbol("published-in-do")].kind == CellKind::Callable); REQUIRE(h.engine.arena.write_head < SOURCE_ARENA_SIZE / 4); h.engine.arena.write_head = SOURCE_ARENA_SIZE - 2; h.eval_ok( "(scope (define published-in-do 8) " "(define published-in-scope (+ 200 30 4)))"); REQUIRE(h.engine.cells.cells[internSymbol("published-in-do")].kind == CellKind::Number); REQUIRE(h.engine.cells.cells[internSymbol("published-in-scope")].kind == CellKind::Callable); REQUIRE(h.engine.arena.write_head < SOURCE_ARENA_SIZE / 4); // Both newly stored callables remain usable after a further compaction. h.eval_ok("(a1 (+ published-in-scope 1))"); REQUIRE(h.sample("a1") == Approx(235.0)); } // ============================================================================ // F5: source-arena exhaustion fails loudly instead of reverting outputs // ============================================================================ TEST_CASE("Reclaim: arena exhaustion fails the eval and never reverts the output", "[reclaim][arena]") { ReclaimHarness h; h.eval_ok("(define off 1)"); h.eval_ok("(a1 (+ off 111))"); REQUIRE(h.sample("a1") == Approx(112.0)); // Exhaust the arena, then try to install a new program. h.engine.arena.write_head = SOURCE_ARENA_SIZE - 4; EvalResult r = h.eval("(a1 (+ off 9999))"); // Must be an explicit error, not a silent success... REQUIRE(r.kind == EvalResult::Error); REQUIRE(r.diagnostic_count >= 1); REQUIRE(r.diagnostics[0].message != nullptr); REQUIRE(std::string(r.diagnostics[0].message).find("storage") != std::string::npos); // ...and the OLD program must still be the active one. REQUIRE(h.sample("a1") == Approx(112.0)); // The killer pre-fix symptom: a later dependency change recompiled the // output from stale source text, silently REVERTING it to the rejected // program's predecessor with mismatched semantics. Now the old program // is still the honestly-active one and follows its dependencies. h.eval_ok("(define off 2)"); REQUIRE(h.sample("a1") == Approx(113.0)); // old program, new off REQUIRE(h.engine.pool.outputs[0].valid); } TEST_CASE("Reclaim: rejected shorter output candidate preserves recompilation source", "[reclaim][arena][rollback]") { ReclaimHarness h; h.eval_ok("(define lifecycle-off 1)"); h.eval_ok("(a1 (+ lifecycle-off 100))"); REQUIRE(h.sample("a1") == Approx(101.0)); EvalResult rejected = h.eval("(a1 nope)"); REQUIRE(rejected.kind == EvalResult::Error); REQUIRE(h.sample("a1") == Approx(101.0)); h.eval_ok("(define lifecycle-off 2)"); REQUIRE(h.sample("a1") == Approx(102.0)); } TEST_CASE("Reclaim: arena exhaustion fails define/defn/defstate loudly", "[reclaim][arena]") { ReclaimHarness h; h.eval_ok("(define x (+ 1 2))"); h.engine.arena.write_head = SOURCE_ARENA_SIZE - 2; EvalResult r1 = h.eval("(define y (+ 3 4))"); REQUIRE(r1.kind == EvalResult::Error); EvalResult r2 = h.eval("(defn f [a] (+ a 1))"); REQUIRE(r2.kind == EvalResult::Error); EvalResult r3 = h.eval("(defstate c 0 (+ c 1))"); REQUIRE(r3.kind == EvalResult::Error); // x's stored source is untouched and still usable. EvalResult get = h.eval("(get-expr x)"); REQUIRE(get.kind == EvalResult::Text); } // ============================================================================ // F8: on_cell_changed refreshes the dependency list // ============================================================================ TEST_CASE("Reclaim: output tracks cells introduced by a redefinition", "[reclaim][deps]") { ReclaimHarness h; h.eval_ok("(define x 1)"); h.eval_ok("(define y 10)"); h.eval_ok("(a1 (* x 2))"); REQUIRE(h.sample("a1") == Approx(2.0)); // Redefine x from a number to an expression referencing y. The // on_cell_changed recompile of a1 must pick up the NEW dep set {y,...}, // not keep the stale {x}-era list. h.eval_ok("(define x (+ y 1))"); REQUIRE(h.sample("a1") == Approx(22.0)); // Pre-fix, this change was invisible to a1 forever. h.eval_ok("(define y 20)"); REQUIRE(h.sample("a1") == Approx(42.0)); // And it keeps tracking on further edits. h.eval_ok("(define y 30)"); REQUIRE(h.sample("a1") == Approx(62.0)); }