c53a49e540
setPath mutated nested sub-arrays in place; since graph-local slices (and their nested sub-slices) may be read concurrently by other flow goroutines, an action.array.set with a multi-level path raced readers of the same local. The single-threaded TS source mutates in place safely, but the Go port runs flows on concurrent goroutines, so setPath now copies on descent (every level returns a freshly allocated slice). Adds a -race regression test that concurrently drives nested set + inner-element reads, plus a nested-set correctness test asserting the prior stored value is not mutated. Found by whole-branch review (Opus); missed by the per-task reviews. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
243 lines
7.7 KiB
Go
243 lines
7.7 KiB
Go
package controllogic
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// runFlowOnce compiles g, wires a minimal engine/context onto the compiled graph
|
|
// (enough for the run/follow path and the lock-free emitDebug guard), then drives
|
|
// the flow from triggerID synchronously and returns the compiled graph so callers
|
|
// can inspect locals.
|
|
func runFlowOnce(t *testing.T, g Graph, triggerID string) *compiledGraph {
|
|
t.Helper()
|
|
cg := compile(g)
|
|
cg.engine = &Engine{}
|
|
cg.genCtx = context.Background()
|
|
R := func(ds, name string) Value {
|
|
switch ds {
|
|
case "local":
|
|
return cg.getLocal(name)
|
|
default:
|
|
return 0.0
|
|
}
|
|
}
|
|
ctx := &runCtx{fired: triggerID, resolve: R}
|
|
cg.follow(triggerID, "out", ctx)
|
|
return cg
|
|
}
|
|
|
|
func TestArrayPushNode(t *testing.T) {
|
|
g := Graph{
|
|
ID: "g", Name: "n",
|
|
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1]", Sizing: "dynamic"}},
|
|
Nodes: []Node{
|
|
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
|
{ID: "p", Kind: "action.array.push", Params: map[string]string{"array": "buf", "expr": "5"}},
|
|
},
|
|
Wires: []Wire{{From: "t", To: "p"}},
|
|
}
|
|
cg := runFlowOnce(t, g, "t")
|
|
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 5.0}) {
|
|
t.Fatalf("after push buf = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestArrayClearNode(t *testing.T) {
|
|
g := Graph{
|
|
ID: "g", Name: "n",
|
|
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "fixed", Capacity: 3}},
|
|
Nodes: []Node{
|
|
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
|
{ID: "c", Kind: "action.array.clear", Params: map[string]string{"array": "buf"}},
|
|
},
|
|
Wires: []Wire{{From: "t", To: "c"}},
|
|
}
|
|
cg := runFlowOnce(t, g, "t")
|
|
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 0.0, 0.0}) {
|
|
t.Fatalf("after clear buf = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestArraySetNode(t *testing.T) {
|
|
g := Graph{
|
|
ID: "g", Name: "n",
|
|
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[0,0,0]", Sizing: "dynamic"}},
|
|
Nodes: []Node{
|
|
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
|
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "buf", "index": "1", "expr": "9"}},
|
|
},
|
|
Wires: []Wire{{From: "t", To: "s"}},
|
|
}
|
|
cg := runFlowOnce(t, g, "t")
|
|
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 9.0, 0.0}) {
|
|
t.Fatalf("after set buf = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestArrayRemoveNode(t *testing.T) {
|
|
g := Graph{
|
|
ID: "g", Name: "n",
|
|
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[10,20,30]", Sizing: "dynamic"}},
|
|
Nodes: []Node{
|
|
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
|
{ID: "r", Kind: "action.array.remove", Params: map[string]string{"array": "buf", "index": "-1"}},
|
|
},
|
|
Wires: []Wire{{From: "t", To: "r"}},
|
|
}
|
|
cg := runFlowOnce(t, g, "t")
|
|
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{10.0, 20.0}) {
|
|
t.Fatalf("after remove buf = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestArrayPopNode(t *testing.T) {
|
|
g := Graph{
|
|
ID: "g", Name: "n",
|
|
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "dynamic"}},
|
|
Nodes: []Node{
|
|
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
|
{ID: "p", Kind: "action.array.pop", Params: map[string]string{"array": "buf"}},
|
|
},
|
|
Wires: []Wire{{From: "t", To: "p"}},
|
|
}
|
|
cg := runFlowOnce(t, g, "t")
|
|
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0}) {
|
|
t.Fatalf("after pop buf = %#v", got)
|
|
}
|
|
}
|
|
|
|
// TestArraySetNodeNested verifies set with a comma-separated path mutates a nested
|
|
// sub-array, and (crucially) that setPath does not mutate the previously stored slice
|
|
// in place — the stored value must be replaced by a fresh tree.
|
|
func TestArraySetNodeNested(t *testing.T) {
|
|
g := Graph{
|
|
ID: "g", Name: "n",
|
|
StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[1,2],[3,4]]", Sizing: "dynamic"}},
|
|
Nodes: []Node{
|
|
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
|
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,1", "expr": "9"}},
|
|
},
|
|
Wires: []Wire{{From: "t", To: "s"}},
|
|
}
|
|
cg := compile(g)
|
|
before := cg.getLocal("grid")
|
|
cg.engine = &Engine{}
|
|
cg.genCtx = context.Background()
|
|
R := func(ds, name string) Value {
|
|
if ds == "local" {
|
|
return cg.getLocal(name)
|
|
}
|
|
return 0.0
|
|
}
|
|
cg.follow("t", "out", &runCtx{fired: "t", resolve: R})
|
|
want := []Value{[]Value{1.0, 9.0}, []Value{3.0, 4.0}}
|
|
if got := cg.getLocal("grid"); !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("after nested set grid = %#v", got)
|
|
}
|
|
// The pre-mutation snapshot must be untouched (no shared-backing in-place write).
|
|
if !reflect.DeepEqual(before, []Value{[]Value{1.0, 2.0}, []Value{3.0, 4.0}}) {
|
|
t.Fatalf("setPath mutated the prior stored value in place: %#v", before)
|
|
}
|
|
}
|
|
|
|
// TestArraySetNodeConcurrentNoRace drives many concurrent set+read flows against the
|
|
// same nested-array local; with -race it guards the setPath copy-on-descend fix.
|
|
func TestArraySetNodeConcurrentNoRace(t *testing.T) {
|
|
g := Graph{
|
|
ID: "g", Name: "n",
|
|
StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[0,0],[0,0]]", Sizing: "dynamic"}},
|
|
Nodes: []Node{
|
|
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
|
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,0", "expr": "1"}},
|
|
},
|
|
Wires: []Wire{{From: "t", To: "s"}},
|
|
}
|
|
cg := compile(g)
|
|
cg.engine = &Engine{}
|
|
cg.genCtx = context.Background()
|
|
R := func(ds, name string) Value {
|
|
if ds == "local" {
|
|
return cg.getLocal(name)
|
|
}
|
|
return 0.0
|
|
}
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 32; i++ {
|
|
wg.Add(2)
|
|
go func() { defer wg.Done(); cg.follow("t", "out", &runCtx{fired: "t", resolve: R}) }()
|
|
go func() {
|
|
defer wg.Done()
|
|
// Read the inner element concurrently; with the in-place setPath bug the
|
|
// writer mutates this same sub-array backing, producing a data race.
|
|
if arr, ok := cg.getLocal("grid").([]Value); ok && len(arr) > 0 {
|
|
if sub, ok := arr[0].([]Value); ok && len(sub) > 0 {
|
|
_ = sub[0]
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestCompileInitsLocalsFromDecls(t *testing.T) {
|
|
g := Graph{
|
|
ID: "g", Name: "n",
|
|
StateVars: []StateVar{
|
|
{Name: "count", Type: "number", Initial: "7"},
|
|
{Name: "flag", Type: "bool", Initial: "true"},
|
|
{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "capped", Capacity: 4},
|
|
},
|
|
}
|
|
cg := compile(g)
|
|
if got := cg.getLocal("count"); got != Value(7.0) {
|
|
t.Fatalf("count = %#v", got)
|
|
}
|
|
if got := cg.getLocal("flag"); got != Value(1.0) {
|
|
t.Fatalf("flag = %#v", got)
|
|
}
|
|
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) {
|
|
t.Fatalf("buf = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestSetLocalAppliesSizing(t *testing.T) {
|
|
g := Graph{ID: "g", Name: "n", StateVars: []StateVar{
|
|
{Name: "buf", Type: "array", Initial: "[]", Sizing: "capped", Capacity: 2},
|
|
}}
|
|
cg := compile(g)
|
|
cg.setLocal("buf", []Value{1.0, 2.0, 3.0, 4.0})
|
|
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{3.0, 4.0}) {
|
|
t.Fatalf("sized buf = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestResolverReturnsLocalValue(t *testing.T) {
|
|
g := Graph{ID: "g", Name: "n", StateVars: []StateVar{
|
|
{Name: "buf", Type: "array", Initial: "[10,20]", Sizing: "dynamic"},
|
|
}}
|
|
cg := compile(g)
|
|
R := func(ds, name string) Value {
|
|
if ds == "local" {
|
|
return cg.getLocal(name)
|
|
}
|
|
return 0.0
|
|
}
|
|
if v := EvalExpr("buf[1]", R); v != 20 {
|
|
t.Fatalf("buf[1] = %v", v)
|
|
}
|
|
if v := EvalExpr("sum(buf)", R); v != 30 {
|
|
t.Fatalf("sum(buf) = %v", v)
|
|
}
|
|
}
|
|
|
|
func TestEmitDebugAcceptsArray(t *testing.T) {
|
|
g := Graph{ID: "g", Name: "n"}
|
|
cg := compile(g)
|
|
cg.engine = &Engine{} // no observer installed; emitDebug must not panic
|
|
cg.emitDebug("x", []Value{1.0, 2.0}, true)
|
|
cg.emitDebug("x", 3.0, true)
|
|
}
|