blob: 242a93310a4cdf3c103fa5362127e33ea09b7e80 [file] [edit]
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package interpreter
import (
"context"
"errors"
"math"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/cel-go/checker"
"github.com/google/cel-go/common"
"github.com/google/cel-go/common/containers"
"github.com/google/cel-go/common/decls"
"github.com/google/cel-go/common/functions"
"github.com/google/cel-go/common/overloads"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/parser"
)
// asyncReturning returns an AsyncOp that immediately produces the given value, while counting the
// number of times the implementation is invoked.
func asyncReturning(val ref.Val, calls *atomic.Int32) functions.AsyncOp {
return func(ctx context.Context, args ...ref.Val) <-chan ref.Val {
if calls != nil {
calls.Add(1)
}
ch := make(chan ref.Val, 1)
ch <- val
close(ch)
return ch
}
}
// newTestFrame creates an ExecutionFrame with an evaluation context attached.
func newTestFrame(t *testing.T, ctx context.Context) (*ExecutionFrame, func()) {
t.Helper()
frame, err := NewExecutionFrame(EmptyActivation())
if err != nil {
t.Fatalf("NewExecutionFrame() failed: %v", err)
}
if err := frame.SetContext(ctx, 0); err != nil {
t.Fatalf("SetContext() failed: %v", err)
}
return frame, frame.Close
}
// awaitResult re-evaluates ComputeResult until it returns a non-Unknown value or the deadline fires.
func awaitResult(t *testing.T, frame *ExecutionFrame, completions <-chan int64, id int64, fn, overload string, impl functions.AsyncOp, args ...ref.Val) ref.Val {
t.Helper()
deadline := time.After(2 * time.Second)
for {
res := frame.ComputeResult(id, fn, overload, impl, args)
if !types.IsUnknown(res) {
return res
}
if unk, ok := res.(*types.Unknown); ok {
frame.DispatchPendingAsyncCalls(unk.IDs())
}
select {
case <-completions:
case <-deadline:
t.Fatal("timed out waiting for async result")
}
}
}
func TestComputeResultWithoutContext(t *testing.T) {
frame, err := NewExecutionFrame(EmptyActivation())
if err != nil {
t.Fatalf("NewExecutionFrame() failed: %v", err)
}
defer frame.Close()
// No SetContext call, so async tracking is uninitialized.
res := frame.ComputeResult(1, "fn", "fn_overload", asyncReturning(types.Int(1), nil), nil)
if !types.IsError(res) {
t.Errorf("ComputeResult() got %v, wanted error when tracking uninitialized", res)
}
if frame.ActiveAsyncCalls() != 0 {
t.Errorf("ActiveAsyncCalls() = %d, wanted 0", frame.ActiveAsyncCalls())
}
if call := frame.AsyncCall(1); call != nil {
t.Errorf("AsyncCall() = %v, wanted nil", call)
}
}
func TestComputeResultResolves(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
frame, closeFrame := newTestFrame(t, ctx)
defer closeFrame()
completions := make(chan int64, 1)
if err := frame.SetCompletions(completions); err != nil {
t.Fatalf("SetCompletions() failed: %v", err)
}
var calls atomic.Int32
impl := asyncReturning(types.Int(42), &calls)
res := awaitResult(t, frame, completions, 1, "fn", "fn_int", impl, types.Int(1))
if res.Equal(types.Int(42)) != types.True {
t.Errorf("async result = %v, wanted 42", res)
}
if got := calls.Load(); got != 1 {
t.Errorf("impl invoked %d times, wanted exactly 1", got)
}
}
func TestTrackerDedupAndCallIDs(t *testing.T) {
// Exercise the tracker directly to keep bookkeeping assertions isolated from the
// process-global frame/tracker pools used by ExecutionFrame.
tracker := newAsyncCallStateTracker()
completions := make(chan int64, 4)
gate := newAsyncGate(0, completions)
impl := asyncReturning(types.Int(1), nil)
// Same node id, same args, invoked repeatedly: must dedup to a single registered call.
a1 := tracker.getOrCreate(1, "fn", "fn_int", []ref.Val{types.Int(1)}, impl, gate)
a2 := tracker.getOrCreate(1, "fn", "fn_int", []ref.Val{types.Int(1)}, impl, gate)
if a1 != a2 {
t.Error("getOrCreate returned distinct states for identical (id, args)")
}
if a1.CallID() != a2.CallID() {
t.Errorf("dedup callIDs differ: %d vs %d", a1.CallID(), a2.CallID())
}
if got := tracker.getByID(a1.CallID()); got != a1 {
t.Errorf("getByID(%d) did not return the registered call", a1.CallID())
}
// Same node id, different args: a new call must be registered (re-evaluation with new inputs).
a3 := tracker.getOrCreate(1, "fn", "fn_int", []ref.Val{types.Int(2)}, impl, gate)
if a3.CallID() == a1.CallID() {
t.Error("arg change reused the prior callID, wanted a fresh call")
}
if got := tracker.getByID(a3.CallID()); got != a3 {
t.Errorf("getByID(%d) did not return the second registered call", a3.CallID())
}
// Registration alone does not mark a call as in-flight; pending counts launched calls only.
if got := gate.ActiveCalls(); got != 0 {
t.Errorf("ActiveCalls() after registration only = %d, wanted 0", got)
}
}
func TestHashCall(t *testing.T) {
type hashInput struct {
id int64
overload string
args []ref.Val
}
tests := []struct {
name string
input hashInput
wantHash uint64
}{
{
name: "node id 1 with string arg a",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.String("a")}},
wantHash: 13175600815575489707,
},
{
name: "node id 1 with string arg b",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.String("b")}},
wantHash: 13172731090226426672,
},
{
name: "node id 1 with bool arg true",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.Bool(true)}},
wantHash: 5368363472817480916,
},
{
name: "node id 1 with bool arg false",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.Bool(false)}},
wantHash: 5369320047933835261,
},
{
name: "node id 1 with nil args",
input: hashInput{1, overloads.ContainsString, nil},
wantHash: 16571860343215109013,
},
{
name: "node id 2 with nil args",
input: hashInput{2, overloads.ContainsString, nil},
wantHash: 7332562693386296450,
},
{
name: "node id 1 with overload matches_string",
input: hashInput{1, overloads.MatchesString, nil},
wantHash: 6112375249444952169,
},
{
name: "node id 1 with overload starts_with_string",
input: hashInput{1, overloads.StartsWithString, nil},
wantHash: 17791913581187873402,
},
{
name: "node id 1 with int arg 1",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.Int(1)}},
wantHash: 6250879603390619476,
},
{
name: "node id 1 with uint arg 1",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.Uint(1)}},
wantHash: 6250879603390619476,
},
{
name: "node id 1 with double arg 1.0",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.Double(1.0)}},
wantHash: 6250879603390619476,
},
{
name: "node id 1 with int arg 2",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.Int(2)}},
wantHash: 2269972419901218387,
},
{
name: "node id 1 with double arg 1.5",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.Double(1.5)}},
wantHash: 1181031487041842476,
},
{
name: "node id 1 with double arg 9.9",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.Double(9.9)}},
wantHash: 6763353195175059609,
},
{
name: "separation a, bc",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.String("a"), types.String("bc")}},
wantHash: 16286284200968323077,
},
{
name: "separation ab, c",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.String("ab"), types.String("c")}},
wantHash: 14974838604657976619,
},
{
name: "node id 1 with double arg NaN",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.Double(math.NaN())}},
wantHash: 4464412628189895594,
},
{
name: "node id 1 with string arg NaN",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.String("NaN")}},
wantHash: 17422508148545277865,
},
{
name: "node id 1 with double arg 0.0",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.Double(0.0)}},
wantHash: 2331193227347896467,
},
{
name: "node id 1 with double arg -0.0",
input: hashInput{1, overloads.ContainsString, []ref.Val{types.Double(math.Copysign(0.0, -1.0))}},
wantHash: 2331193227347896467,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := hashCall(tc.input.id, tc.input.overload, tc.input.args)
if got != tc.wantHash {
t.Errorf("hashCall() = %d, wanted %d", got, tc.wantHash)
}
})
}
}
func TestTrackerComprehensionReuse(t *testing.T) {
// Regression: a single AST node id evaluated repeatedly with distinct argument values (as
// happens for an async call inside a comprehension) must register one call per distinct
// argument set, and re-lookups must return the existing state rather than relaunching. A
// node-id-keyed map collapses these into a single slot, causing every re-evaluation pass to
// relaunch every iteration and never converge.
tracker := newAsyncCallStateTracker()
completions := make(chan int64, 8)
gate := newAsyncGate(0, completions)
impl := asyncReturning(types.Int(0), nil)
const id = int64(1)
args := [][]ref.Val{
{types.Int(1)},
{types.Int(2)},
{types.Int(3)},
}
states := make([]*asyncCallState, len(args))
for i, a := range args {
states[i] = tracker.getOrCreate(id, "fn", "fn_int", a, impl, gate)
}
// Each distinct argument set is a distinct, uniquely-identified call.
seen := map[int64]bool{}
for _, s := range states {
if seen[s.CallID()] {
t.Errorf("duplicate callID %d across distinct args", s.CallID())
}
seen[s.CallID()] = true
}
// Re-evaluation: every prior (id, args) tuple must resolve to its existing state and must
// not register a new call.
for i, a := range args {
if got := tracker.getOrCreate(id, "fn", "fn_int", a, impl, gate); got != states[i] {
t.Errorf("re-lookup of args %v returned a new state, wanted the existing one", a)
}
}
// Identical string arguments at the same node dedup to a single call.
s1 := tracker.getOrCreate(2, "fn", "fn_str", []ref.Val{types.String("k")}, impl, gate)
s2 := tracker.getOrCreate(2, "fn", "fn_str", []ref.Val{types.String("k")}, impl, gate)
if s1 != s2 {
t.Error("identical string args did not dedup to a single call")
}
}
func TestTrackerRegistrationLookup(t *testing.T) {
tracker := newAsyncCallStateTracker()
completions := make(chan int64, 2)
gate := newAsyncGate(0, completions)
impl := asyncReturning(types.Int(1), nil)
a := tracker.getOrCreate(1, "fn", "a", []ref.Val{types.Int(1)}, impl, gate)
b := tracker.getOrCreate(2, "fn", "b", []ref.Val{types.Int(2)}, impl, gate)
// Registered calls must be retrievable by callID.
for _, want := range []*asyncCallState{a, b} {
if got := tracker.getByID(want.CallID()); got != want {
t.Errorf("getByID(%d) returned a different call record", want.CallID())
}
}
// Unknown callIDs resolve to nil rather than panicking.
if got := tracker.getByID(99999); got != nil {
t.Errorf("getByID(unknown) = %v, wanted nil", got)
}
}
func TestAsyncObserverLifecycle(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
frame, closeFrame := newTestFrame(t, ctx)
defer closeFrame()
obs := &recordingObserver{}
if err := frame.SetAsyncObserver(obs); err != nil {
t.Fatalf("SetAsyncObserver() failed: %v", err)
}
completions := make(chan int64, 1)
if err := frame.SetCompletions(completions); err != nil {
t.Fatalf("SetCompletions() failed: %v", err)
}
awaitResult(t, frame, completions, 1, "fn", "fn_int", asyncReturning(types.Int(7), nil), types.Int(1))
if got := obs.started.Load(); got != 1 {
t.Errorf("OnCallStarted called %d times, wanted 1", got)
}
if got := obs.finished.Load(); got != 1 {
t.Errorf("OnCallFinished called %d times, wanted 1", got)
}
}
func TestAsyncObserverOnCancellation(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
frame, closeFrame := newTestFrame(t, ctx)
defer closeFrame()
type finishedEvent struct {
callID int64
function string
overload string
res ref.Val
}
finishedChan := make(chan finishedEvent, 1)
obs := &recordingObserverWithCallback{
onFinished: func(callID int64, function, overload string, res ref.Val) {
finishedChan <- finishedEvent{callID, function, overload, res}
},
}
if err := frame.SetAsyncObserver(obs); err != nil {
t.Fatalf("SetAsyncObserver() failed: %v", err)
}
release := make(chan struct{})
defer close(release)
var live, maxLive atomic.Int32
impl := asyncControllable(release, &live, &maxLive)
res := frame.ComputeResult(1, "fn", "fn_int", impl, []ref.Val{types.Int(1)})
if unk, ok := res.(*types.Unknown); ok {
frame.DispatchPendingAsyncCalls(unk.IDs())
}
if !types.IsUnknown(res) {
t.Fatalf("ComputeResult() got %v, wanted Unknown", res)
}
cancelErr := errors.New("aborted by user request")
cancel(cancelErr)
select {
case event := <-finishedChan:
if event.callID != 1 {
t.Errorf("observer got callID = %d, wanted 1", event.callID)
}
if event.function != "fn" || event.overload != "fn_int" {
t.Errorf("observer got func/overload = %q/%q, wanted fn/fn_int", event.function, event.overload)
}
if !types.IsError(event.res) {
t.Errorf("observer result got %v, wanted error", event.res)
} else {
errVal := event.res.(*types.Err)
if !strings.Contains(errVal.Error(), "aborted by user request") {
t.Errorf("expected observer error to contain cause, got: %v", errVal)
}
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for observer completion notification on context cancel")
}
}
type recordingObserverWithCallback struct {
onFinished func(callID int64, function, overload string, res ref.Val)
}
func (o *recordingObserverWithCallback) OnCallStarted(callID int64, function, overload string, args []ref.Val) {
}
func (o *recordingObserverWithCallback) OnCallFinished(callID int64, function, overload string, res ref.Val) {
if o.onFinished != nil {
o.onFinished(callID, function, overload, res)
}
}
// asyncControllable returns a channel-based AsyncOp whose calls block until release is closed,
// tracking the number of concurrently live calls and the high-water mark.
func asyncControllable(release <-chan struct{}, live, maxLive *atomic.Int32) functions.AsyncOp {
return func(ctx context.Context, args ...ref.Val) <-chan ref.Val {
ch := make(chan ref.Val, 1)
go func() {
cur := live.Add(1)
for {
old := maxLive.Load()
if cur <= old || maxLive.CompareAndSwap(old, cur) {
break
}
}
select {
case <-release:
case <-ctx.Done():
}
live.Add(-1)
ch <- args[0]
close(ch)
}()
return ch
}
}
func TestLaunchAdmissionAndBounding(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
frame, closeFrame := newTestFrame(t, ctx)
defer closeFrame()
const limit = 2
const total = 5
if err := frame.SetAsyncMaxConcurrency(limit); err != nil {
t.Fatalf("SetAsyncMaxConcurrency() failed: %v", err)
}
completions := make(chan int64, total*2)
if err := frame.SetCompletions(completions); err != nil {
t.Fatalf("SetCompletions() failed: %v", err)
}
release := make(chan struct{})
var live, maxLive atomic.Int32
impl := asyncControllable(release, &live, &maxLive)
// A single evaluation pass: attempt to launch all calls (distinct node ids).
pass := func() {
for i := range total {
res := frame.ComputeResult(int64(i+1), "fn", "fn_int", impl, []ref.Val{types.Int(int64(i))})
if unk, ok := res.(*types.Unknown); ok {
frame.DispatchPendingAsyncCalls(unk.IDs())
}
}
}
// First pass admits only `limit` launches; the rest are deferred. pending is updated
// synchronously as calls are admitted, so it must equal the limit immediately.
pass()
if got := frame.ActiveAsyncCalls(); got != limit {
t.Fatalf("ActiveAsyncCalls() after first pass = %d, wanted %d", got, limit)
}
// Drive completions and re-evaluate until every call has resolved, simulating ConcurrentEval.
close(release)
done := map[int64]bool{}
deadline := time.After(5 * time.Second)
for len(done) < total {
pass() // re-evaluate: launch any newly-admissible calls
select {
case id := <-completions:
done[id] = true
case <-deadline:
t.Fatalf("only %d/%d calls completed; maxLive=%d", len(done), total, maxLive.Load())
}
}
if got := maxLive.Load(); got > limit {
t.Errorf("max concurrent launches = %d, wanted <= %d", got, limit)
}
if got := frame.ActiveAsyncCalls(); got != 0 {
t.Errorf("ActiveAsyncCalls() after all completions = %d, wanted 0", got)
}
}
func TestLaunchUnlimitedWhenNoSemaphore(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
frame, closeFrame := newTestFrame(t, ctx)
defer closeFrame()
// No SetAsyncMaxConcurrency -> nil semaphore -> all calls admitted in one pass.
completions := make(chan int64, 8)
if err := frame.SetCompletions(completions); err != nil {
t.Fatalf("SetCompletions() failed: %v", err)
}
release := make(chan struct{})
defer close(release)
var live, maxLive atomic.Int32
impl := asyncControllable(release, &live, &maxLive)
const total = 5
for i := 0; i < total; i++ {
res := frame.ComputeResult(int64(i+1), "fn", "fn_int", impl, []ref.Val{types.Int(int64(i))})
if unk, ok := res.(*types.Unknown); ok {
frame.DispatchPendingAsyncCalls(unk.IDs())
}
}
if got := frame.ActiveAsyncCalls(); got != total {
t.Errorf("ActiveAsyncCalls() with no limit = %d, wanted %d", got, total)
}
}
func TestAsyncCallStateCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
frame, closeFrame := newTestFrame(t, ctx)
defer closeFrame()
completions := make(chan int64, 1)
if err := frame.SetCompletions(completions); err != nil {
t.Fatalf("SetCompletions() failed: %v", err)
}
var exited sync.WaitGroup
exited.Add(1)
blocking := func(ctx context.Context, args ...ref.Val) <-chan ref.Val {
ch := make(chan ref.Val) // never written; goroutine must exit via ctx.Done
go func() {
defer exited.Done()
<-ctx.Done()
}()
return ch
}
res := frame.ComputeResult(1, "fn", "fn_int", blocking, []ref.Val{types.Int(1)})
if unk, ok := res.(*types.Unknown); ok {
frame.DispatchPendingAsyncCalls(unk.IDs())
}
if !types.IsUnknown(res) {
t.Fatalf("ComputeResult() = %v, wanted Unknown while pending", res)
}
cancel()
done := make(chan struct{})
go func() { exited.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("async impl goroutine did not exit on cancellation")
}
// No completion should have been delivered for the cancelled call.
select {
case callID := <-completions:
t.Errorf("unexpected completion for cancelled call: %d", callID)
default:
}
}
func TestAsyncTrackerPoolReleaseClearsState(t *testing.T) {
tracker := newAsyncCallStateTracker()
completions := make(chan int64, 4)
gate := newAsyncGate(0, completions)
for i := int64(1); i <= 3; i++ {
tracker.getOrCreate(i, "fn", "ov", []ref.Val{types.Int(i)}, asyncReturning(types.Int(i), nil), gate)
}
if tracker.getByID(2) == nil {
t.Fatal("getByID(2) = nil before release, wanted a call record")
}
asyncCallStateTrackerPool.release(tracker)
if got := len(tracker.calls); got != 0 {
t.Errorf("calls map size after release = %d, wanted 0", got)
}
if got := len(tracker.callsByID); got != 0 {
t.Errorf("callsByID map size after release = %d, wanted 0", got)
}
if got := tracker.nextCallID.Load(); got != 0 {
t.Errorf("nextCallID after release = %d, wanted 0", got)
}
// A released tracker is safe to reuse: callIDs restart and bookkeeping is fresh.
acs := tracker.getOrCreate(1, "fn", "ov", []ref.Val{types.Int(1)}, asyncReturning(types.Int(1), nil), gate)
if acs.CallID() != 1 {
t.Errorf("reused tracker assigned callID %d, wanted 1", acs.CallID())
}
}
func TestAsyncCallStateMatches(t *testing.T) {
mk := func(fn, ov string, args ...ref.Val) *asyncCallState {
return newAsyncCallState(1, fn, ov, args, nil)
}
base := mk("fn", "ov", types.Int(1), types.String("a"))
tests := []struct {
name string
other *asyncCallState
want bool
}{
{"identical", mk("fn", "ov", types.Int(1), types.String("a")), true},
{"diff function", mk("other", "ov", types.Int(1), types.String("a")), false},
{"diff overload", mk("fn", "other", types.Int(1), types.String("a")), false},
{"diff arg value", mk("fn", "ov", types.Int(2), types.String("a")), false},
{"diff arity", mk("fn", "ov", types.Int(1)), false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := base.matches(1, tc.other.function, tc.other.overload, tc.other.argVals); got != tc.want {
t.Errorf("matches() = %v, wanted %v", got, tc.want)
}
})
}
t.Run("nil receiver", func(t *testing.T) {
var nilState *asyncCallState
if nilState.matches(1, base.function, base.overload, base.argVals) {
t.Error("nil.matches() returned true")
}
})
t.Run("NaN equivalence", func(t *testing.T) {
s1 := mk("fn", "ov", types.Double(math.NaN()))
s2 := mk("fn", "ov", types.Double(math.NaN()))
s3 := mk("fn", "ov", types.Double(1.23))
tests := []struct {
name string
state *asyncCallState
other *asyncCallState
want bool
}{
{"NaN vs NaN", s1, s2, true},
{"NaN vs non-NaN", s1, s3, false},
{"non-NaN vs NaN", s3, s1, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.state.matches(1, tc.other.function, tc.other.overload, tc.other.argVals); got != tc.want {
t.Errorf("matches() = %v, wanted %v", got, tc.want)
}
})
}
})
}
func TestExecutionFrameChildSharesAsyncContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
frame, closeFrame := newTestFrame(t, ctx)
defer closeFrame()
completions := make(chan int64, 1)
if err := frame.SetCompletions(completions); err != nil {
t.Fatalf("SetCompletions() failed: %v", err)
}
// Counts are taken relative to a baseline, since ExecutionFrame draws its tracker from a
// process-global pool whose starting pending count is not guaranteed to be zero.
base := frame.ActiveAsyncCalls()
child := frame.Push(EmptyActivation())
if got := child.ActiveAsyncCalls(); got != base {
t.Errorf("child ActiveAsyncCalls() = %d, wanted %d (shared parent ctx)", got, base)
}
// A call launched from the child frame must be visible through the shared parent tracker.
resChild := child.ComputeResult(1, "fn", "fn_int", asyncReturning(types.Int(5), nil), []ref.Val{types.Int(1)})
if unk, ok := resChild.(*types.Unknown); ok {
child.DispatchPendingAsyncCalls(unk.IDs())
}
if got := child.ActiveAsyncCalls(); got != base+1 {
t.Errorf("child ActiveAsyncCalls() after launch = %d, wanted %d", got, base+1)
}
parent := child.Pop()
if parent != frame {
t.Fatalf("Pop() did not return the parent frame")
}
if got := frame.ActiveAsyncCalls(); got != base+1 {
t.Errorf("parent ActiveAsyncCalls() after Pop = %d, wanted %d", got, base+1)
}
// The launched call is retrievable via the parent frame's AsyncCall accessor.
select {
case callID := <-completions:
if call := frame.AsyncCall(callID); call == nil {
t.Errorf("AsyncCall(%d) = nil, wanted a call record from the shared tracker", callID)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for child call completion")
}
}
type recordingObserver struct {
started atomic.Int32
finished atomic.Int32
}
func (o *recordingObserver) OnCallStarted(callID int64, function, overload string, args []ref.Val) {
o.started.Add(1)
}
func (o *recordingObserver) OnCallFinished(callID int64, function, overload string, res ref.Val) {
o.finished.Add(1)
}
func TestEvalAsyncFuncGetters(t *testing.T) {
fn := &evalAsyncFunc{
id: 42,
function: "async_fn",
overload: "async_fn_overload",
args: []InterpretableV2{
&evalConst{val: types.Int(1)},
},
impl: asyncReturning(types.Int(100), nil),
}
if fn.ID() != 42 {
t.Errorf("ID() = %d, wanted 42", fn.ID())
}
if fn.Function() != "async_fn" {
t.Errorf("Function() = %s, wanted async_fn", fn.Function())
}
if fn.OverloadID() != "async_fn_overload" {
t.Errorf("OverloadID() = %s, wanted async_fn_overload", fn.OverloadID())
}
if len(fn.Args()) != 1 {
t.Errorf("Args() length = %d, wanted 1", len(fn.Args()))
}
}
func TestEvalAsyncFuncLifecycle(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
frame, closeFrame := newTestFrame(t, ctx)
defer closeFrame()
completions := make(chan int64, 1)
if err := frame.SetCompletions(completions); err != nil {
t.Fatalf("SetCompletions() failed: %v", err)
}
impl := asyncReturning(types.Int(100), nil)
fn := &evalAsyncFunc{
id: 42,
function: "async_fn",
overload: "async_fn_overload",
args: []InterpretableV2{
&evalConst{val: types.Int(1)},
},
impl: impl,
}
// Test Exec first pass
res := fn.Exec(frame)
if !types.IsUnknown(res) {
t.Errorf("Exec() first call got %v, wanted Unknown", res)
}
if unk, ok := res.(*types.Unknown); ok {
frame.DispatchPendingAsyncCalls(unk.IDs())
}
// Now await completion and re-evaluate
select {
case <-completions:
case <-time.After(time.Second):
t.Fatal("timed out waiting for completion")
}
res2 := fn.Exec(frame)
if res2.Equal(types.Int(100)) != types.True {
t.Errorf("Exec() second call got %v, wanted 100", res2)
}
// Test Eval method
res3 := fn.Eval(frame)
if res3.Equal(types.Int(100)) != types.True {
t.Errorf("Eval() got %v, wanted 100", res3)
}
}
func TestEvalAsyncFuncEarlyReturn(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
frame, closeFrame := newTestFrame(t, ctx)
defer closeFrame()
impl := asyncReturning(types.Int(100), nil)
// Test Exec returning early when an argument is error
fnErrArg := &evalAsyncFunc{
id: 43,
function: "async_fn",
overload: "async_fn_overload",
args: []InterpretableV2{
&evalConst{val: types.NewErr("argument error")},
},
impl: impl,
}
resErr := fnErrArg.Exec(frame)
if !types.IsError(resErr) {
t.Errorf("Exec with error arg got %v, wanted error", resErr)
}
// Test Exec returning early when an argument is unknown
fnUnknownArg := &evalAsyncFunc{
id: 44,
function: "async_fn",
overload: "async_fn_overload",
args: []InterpretableV2{
&evalConst{val: types.NewUnknown(999, nil)},
},
impl: impl,
}
resUnknown := fnUnknownArg.Exec(frame)
if !types.IsUnknown(resUnknown) {
t.Errorf("Exec with unknown arg got %v, wanted Unknown", resUnknown)
}
}
func TestTrackerPoolShrink(t *testing.T) {
pool := newAsyncCallTrackerPool()
tracker := pool.create()
completions := make(chan int64, 1)
gate := newAsyncGate(0, completions)
impl := asyncReturning(types.Int(1), nil)
// Register more than trackerShrinkThreshold calls to trigger map reallocation
for i := int64(1); i <= trackerShrinkThreshold+10; i++ {
tracker.getOrCreate(i, "fn", "fn_int", []ref.Val{types.Int(i)}, impl, gate)
}
// Release the tracker; since size exceeds threshold, maps should be reallocated
pool.release(tracker)
// Retrieve again from pool to make sure it's clean and empty
tracker2 := pool.create()
if len(tracker2.calls) != 0 || len(tracker2.callsByID) != 0 {
t.Errorf("released tracker was not cleared: calls size = %d, callsByID size = %d", len(tracker2.calls), len(tracker2.callsByID))
}
// Now register a small number of calls, release, and check that delete path works
tracker2.getOrCreate(1, "fn", "fn_int", []ref.Val{types.Int(1)}, impl, gate)
pool.release(tracker2)
tracker3 := pool.create()
if len(tracker3.calls) != 0 || len(tracker3.callsByID) != 0 {
t.Errorf("released tracker (delete path) was not cleared: calls size = %d, callsByID size = %d", len(tracker3.calls), len(tracker3.callsByID))
}
pool.release(tracker3)
}
func TestAsyncWithTraceAndExhaustiveEval(t *testing.T) {
asyncFnDecl, err := decls.NewFunction("async_eq_true",
decls.Overload("async_eq_true_int",
[]*types.Type{types.IntType},
types.BoolType,
decls.AsyncBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
val := args[0].(types.Int)
select {
case <-time.After(50 * time.Millisecond):
return types.Bool(val == 1)
case <-ctx.Done():
return types.NewErr("cancelled")
}
}),
),
)
if err != nil {
t.Fatalf("NewFunction failed: %v", err)
}
exprStr := "async_eq_true(1) && async_eq_true(2)"
s := common.NewTextSource(exprStr)
p, err := parser.NewParser(
parser.Macros(parser.AllMacros...),
)
if err != nil {
t.Fatalf("NewParser failed: %v", err)
}
parsed, errs := p.Parse(s)
if len(errs.GetErrors()) != 0 {
t.Fatalf("Parse errors: %s", errs.ToDisplayString())
}
reg := newTestRegistry(t)
env := newTestEnv(t, containers.DefaultContainer, reg)
err = env.AddFunctions(asyncFnDecl)
if err != nil {
t.Fatalf("AddFunctions failed: %v", err)
}
checked, errs := checker.Check(parsed, s, env)
if len(errs.GetErrors()) != 0 {
t.Fatalf("Check errors: %s", errs.ToDisplayString())
}
disp := NewDispatcher()
addFunctionBindings(t, disp)
bindings, err := asyncFnDecl.Bindings()
if err != nil {
t.Fatalf("Bindings() failed: %v", err)
}
err = disp.Add(bindings...)
if err != nil {
t.Fatalf("dispatcher.Add() failed: %v", err)
}
attrs := NewAttributeFactory(containers.DefaultContainer, reg, reg)
interp := NewInterpreter(disp, containers.DefaultContainer, reg, reg, attrs)
state := NewEvalState()
prg, err := interp.NewInterpretable(checked,
ExhaustiveEval(),
EvalStateObserver(EvalStateFactory(func() EvalState { return state })),
)
if err != nil {
t.Fatalf("NewInterpretable failed: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
frame, err := NewExecutionFrame(EmptyActivation())
if err != nil {
t.Fatalf("NewExecutionFrame failed: %v", err)
}
defer frame.Close()
if err := frame.SetContext(ctx, 0); err != nil {
t.Fatalf("SetContext failed: %v", err)
}
completions := make(chan int64, 4)
if err := frame.SetCompletions(completions); err != nil {
t.Fatalf("SetCompletions() failed: %v", err)
}
oi, isObservable := prg.(*ObservableInterpretable)
if !isObservable {
t.Fatalf("expected program to be observable")
}
runPass := func() ref.Val {
return oi.ObserveExec(frame, func(observed any) {
})
}
var res ref.Val
deadline := time.After(2 * time.Second)
loop:
for {
res = runPass()
if !types.IsUnknown(res) && frame.ActiveAsyncCalls() == 0 {
break loop
}
if unk, ok := res.(*types.Unknown); ok {
frame.DispatchPendingAsyncCalls(unk.IDs())
}
select {
case <-completions:
case <-deadline:
t.Fatal("timed out waiting for async result")
}
}
if res.Equal(types.False) != types.True {
t.Errorf("expected false, got %v", res)
}
state.Reset()
resLast := runPass()
if resLast.Equal(types.False) != types.True {
t.Errorf("expected final pass result to be false, got %v", resLast)
}
for _, id := range state.IDs() {
val, found := state.Value(id)
if !found {
continue
}
if types.IsUnknown(val) {
t.Errorf("found unknown value in final eval state for node %d: %v", id, val)
}
}
}
func TestAsyncSetupWithoutContextErrors(t *testing.T) {
frame, err := NewExecutionFrame(EmptyActivation())
if err != nil {
t.Fatalf("NewExecutionFrame() failed: %v", err)
}
defer frame.Close()
// No frame.SetContext called, so context is nil.
completions := make(chan int64, 1)
if err := frame.SetCompletions(completions); err == nil {
t.Error("SetCompletions() succeeded without context, wanted error")
}
obs := &recordingObserver{}
if err := frame.SetAsyncObserver(obs); err == nil {
t.Error("SetAsyncObserver() succeeded without context, wanted error")
}
if err := frame.SetAsyncMaxConcurrency(2); err == nil {
t.Error("SetAsyncMaxConcurrency() succeeded without context, wanted error")
}
}