599 lines
24 KiB
C++
599 lines
24 KiB
C++
|
|
// 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 <cstdio>
|
||
|
|
#include <cstring>
|
||
|
|
#include <string>
|
||
|
|
|
||
|
|
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<uint32_t>(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));
|
||
|
|
}
|