Skip to content
Merged
183 changes: 183 additions & 0 deletions pkg/workflow/behavior_defined_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"maps"
"slices"
"strings"
"sync"
"time"

"github.com/github/gh-aw/pkg/constants"
Expand Down Expand Up @@ -502,10 +503,48 @@ func isEngineDefinitionForm(def *EngineDefinition) bool {
return def.Models.Default != "" || len(def.Models.Supported) > 0 || len(def.Options) > 0
}

// engineDefinitionBuiltinKeys is the set of JSON strings corresponding to
// built-in engine definitions. It is populated once at startup by
// loadBuiltinEngineDefinitions (via registerBuiltinEngineDefinitionJSON) and
// never modified afterward. Only JSON keys present in this set are eligible for
// caching in engineDefinitionCache, which prevents unbounded growth in long-lived
// compile --watch sessions where each edit to a custom engine definition would
// otherwise create a new cache entry.
var engineDefinitionBuiltinKeys sync.Map // map[string]struct{}

// registerBuiltinEngineDefinitionJSON marks jsonKey as a known built-in engine
// JSON string so that parseEngineDefinitionFromJSON will cache it.
func registerBuiltinEngineDefinitionJSON(jsonKey string) {
engineDefinitionBuiltinKeys.Store(jsonKey, struct{}{})
}

// engineDefinitionCache caches parsed EngineDefinition values for built-in
// engine JSON strings. Built-in engine files are injected as imports on every
// CompileWorkflow call and their JSON representation is always identical, so
// caching avoids the repeated JSON→any→YAML→struct round-trip that accounted
// for ~24% of BenchmarkCompileMCPWorkflow wall-clock time.
//
// Only keys present in engineDefinitionBuiltinKeys are stored to bound the cache
// to the fixed set of built-in engines. Deep copies are returned on every lookup
// so callers cannot corrupt the cached state through mutations to pointers, slices,
// or maps within the returned definition.
var engineDefinitionCache sync.Map // map[string]EngineDefinition

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 54898e7. Added engineDefinitionBuiltinKeys sync.Map pre-seeded at startup by loadBuiltinEngineDefinitions. parseEngineDefinitionFromJSON now only stores to the cache when the incoming JSON key is present in that set, so custom engine edits in --watch sessions never add entries.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded cache with no eviction grows forever on diverse inputs: engineDefinitionCache is a package-level sync.Map with no size cap, no TTL, and no eviction. Keys are full JSON strings; values are EngineDefinition structs (which contain maps and slices). If parseEngineDefinitionFromJSON is ever called with user-supplied or dynamically-varied JSON (not just the small set of built-in engines), the cache is a memory leak for the process lifetime.

💡 Suggested fix

If the intent is strictly to cache built-in engine definitions (a small, bounded set), enforce that at the call site or document the cardinality assumption in a //nolint comment and add a test assertion:

// engineDefinitionCache is bounded: only built-in engine JSON strings (of which
// there are O(10)) are ever passed to parseEngineDefinitionFromJSON.
// If that invariant changes, replace this with an LRU cache.
var engineDefinitionCache sync.Map

Alternatively, add a max-size guard at store time and log a warning when the threshold is exceeded.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 54898e7. The cache is now strictly bounded to built-in engine JSON strings via engineDefinitionBuiltinKeys (pre-seeded from the embedded data/engines/*.md files at startup). Non-builtin JSON is never stored; TestParseEngineDefinitionFromJSON_NonBuiltinNotCached enforces this invariant.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No cache reset mechanism makes tests non-hermetic: The package-level engineDefinitionCache persists across all test runs in the same process. If parseEngineDefinitionFromJSON is called in one test with a given JSON string, subsequent tests — or a future bug fix that changes parse behavior for the same input — will silently receive the stale cached result.

💡 Suggested fix

Add an unexported reset helper usable in tests:

// resetEngineDefinitionCacheForTest clears the cache. Call in TestMain or individual test setup.
func resetEngineDefinitionCacheForTest() {
    engineDefinitionCache.Range(func(k, _ any) bool {
        engineDefinitionCache.Delete(k)
        return true
    })
}

Or inject the cache as a dependency rather than relying on global state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 54898e7. Added resetEngineDefinitionCacheForTest which clears both engineDefinitionCache and engineDefinitionBuiltinKeys. All new cache tests call it via defer to ensure hermetic isolation between test runs.


func parseEngineDefinitionFromJSON(engineJSON string) (*EngineDefinition, error) {
if engineJSON == "" {
return nil, nil
}
// Fast path: return a deep copy of the cached definition when available.
if cached, ok := engineDefinitionCache.Load(engineJSON); ok {
if def, ok := cached.(EngineDefinition); ok {
defCopy := deepCopyEngineDefinition(def)
return &defCopy, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shallow copy does not protect the cache from mutation: defCopy := def is a struct copy, but EngineDefinition contains reference fields (Options map[string]any, Behaviors *EngineBehaviorDefinition, Auth []AuthBinding) that are shared with the cached entry. Any caller that mutates Options or follows and modifies Behaviors will silently corrupt the cached value, affecting every subsequent caller — directly contradicting the comment's safety claim.

💡 Suggested fix

A true defensive copy requires a deep clone. The simplest correct approach is to re-encode to JSON and decode into a fresh struct, accepting that cache hits are slightly more expensive than a struct copy:

func cloneEngineDefinition(src EngineDefinition) (EngineDefinition, error) {
    b, err := json.Marshal(src)
    if err != nil {
        return EngineDefinition{}, err
    }
    var dst EngineDefinition
    return dst, json.Unmarshal(b, &dst)
}

Or, if callers are known not to mutate returned fields, document that contract explicitly and enforce it with a lint rule rather than relying on a copy that only looks protective.

The bug is present in both the cache-hit path (line 523) and the cache-miss path (line 551).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 54898e7. Replaced the shallow struct copy with deepCopyEngineDefinition which recursively copies all reference types. The test TestParseEngineDefinitionFromJSON_CacheHitReturnsDeepCopy specifically validates that mutating Options on a returned definition does not affect subsequent cache hits.

}
// Type assertion failure indicates cache corruption or a concurrent Store with
// an unexpected type. Log and fall through to re-parse so the caller still works.
behaviorDefinedEngineLog.Printf("engineDefinitionCache: unexpected value type %T for key (len=%d); re-parsing", cached, len(engineJSON))
engineDefinitionCache.Delete(engineJSON)
}
var engineData any
if err := json.Unmarshal([]byte(engineJSON), &engineData); err != nil {
return nil, fmt.Errorf("failed to parse engine JSON: %w", err)
Expand All @@ -524,9 +563,153 @@ func parseEngineDefinitionFromJSON(engineJSON string) (*EngineDefinition, error)
if def.RuntimeID == "" {
def.RuntimeID = def.ID
}
// Cache only built-in engine definitions (keys pre-seeded by loadBuiltinEngineDefinitions)
// to prevent unbounded memory growth. Store a deep copy so that any mutations the
// caller makes to the returned definition cannot corrupt the cached entry.
if _, isBuiltin := engineDefinitionBuiltinKeys.Load(engineJSON); isBuiltin {
cacheCopy := deepCopyEngineDefinition(def)
engineDefinitionCache.Store(engineJSON, cacheCopy)
}
return &def, nil
}

// deepCopyAny returns a fully independent copy of v for values produced by
// yaml.Unmarshal into interface{}. The possible concrete types are:
// nil, bool, int, float64, string, []any, and map[string]any.
func deepCopyAny(v any) any {
switch val := v.(type) {
case map[string]any:
cp := make(map[string]any, len(val))
for k, elem := range val {
cp[k] = deepCopyAny(elem)
}
return cp
case []any:
cp := make([]any, len(val))
for i, elem := range val {
cp[i] = deepCopyAny(elem)
}
return cp
default:
// Scalars (nil, bool, int, float64, string) are immutable value types.
return v
}
}

// deepCopyEngineDefinition returns a fully independent copy of src. All reference
// types (pointers, slices, maps) are recursively copied so that neither the caller
// nor the cache can corrupt the other's state through shared references.
func deepCopyEngineDefinition(src EngineDefinition) EngineDefinition {
dst := src // value copy covers all scalar fields

// Models.Supported
if src.Models.Supported != nil {
dst.Models.Supported = make([]string, len(src.Models.Supported))
copy(dst.Models.Supported, src.Models.Supported)
}

// Auth ([]AuthBinding; elements contain only string fields so element copy suffices)
if src.Auth != nil {
dst.Auth = make([]AuthBinding, len(src.Auth))
copy(dst.Auth, src.Auth)
}

// Options (map[string]any; values may contain nested maps or slices from YAML unmarshal)
if src.Options != nil {
dst.Options = make(map[string]any, len(src.Options))
for k, v := range src.Options {
dst.Options[k] = deepCopyAny(v)
}
}

// Provider.Auth
if src.Provider.Auth != nil {
authCopy := *src.Provider.Auth // AuthDefinition contains only string fields
dst.Provider.Auth = &authCopy
}

// Provider.Request
if src.Provider.Request != nil {
reqCopy := *src.Provider.Request
if src.Provider.Request.Query != nil {
reqCopy.Query = make(map[string]string, len(src.Provider.Request.Query))
maps.Copy(reqCopy.Query, src.Provider.Request.Query)
}
if src.Provider.Request.BodyInject != nil {
reqCopy.BodyInject = make(map[string]string, len(src.Provider.Request.BodyInject))
maps.Copy(reqCopy.BodyInject, src.Provider.Request.BodyInject)
}
dst.Provider.Request = &reqCopy
}

// Behaviors
if src.Behaviors != nil {
behaviorsCopy := deepCopyEngineBehaviorDefinition(*src.Behaviors)
dst.Behaviors = &behaviorsCopy
}

return dst
}

// deepCopyEngineBehaviorDefinition returns a fully independent copy of src.
func deepCopyEngineBehaviorDefinition(src EngineBehaviorDefinition) EngineBehaviorDefinition {
dst := src // value copy covers all scalar fields

// SupportedEnvVarKeys
if src.SupportedEnvVarKeys != nil {
dst.SupportedEnvVarKeys = make([]string, len(src.SupportedEnvVarKeys))
copy(dst.SupportedEnvVarKeys, src.SupportedEnvVarKeys)
}

// Manifest
if src.Manifest != nil {
manifestCopy := *src.Manifest
if src.Manifest.Files != nil {
manifestCopy.Files = make([]string, len(src.Manifest.Files))
copy(manifestCopy.Files, src.Manifest.Files)
}
if src.Manifest.PathPrefixes != nil {
manifestCopy.PathPrefixes = make([]string, len(src.Manifest.PathPrefixes))
copy(manifestCopy.PathPrefixes, src.Manifest.PathPrefixes)
}
dst.Manifest = &manifestCopy
}

// Installation (only scalar fields; pointer dereference suffices)
if src.Installation != nil {
installCopy := *src.Installation
dst.Installation = &installCopy
}

// ConfigFile (only scalar fields)
if src.ConfigFile != nil {
cfCopy := *src.ConfigFile
dst.ConfigFile = &cfCopy
}

// Execution (has Args []string and Env map[string]string)
if src.Execution != nil {
execCopy := *src.Execution
if src.Execution.Args != nil {
execCopy.Args = make([]string, len(src.Execution.Args))
copy(execCopy.Args, src.Execution.Args)
}
if src.Execution.Env != nil {
execCopy.Env = make(map[string]string, len(src.Execution.Env))
maps.Copy(execCopy.Env, src.Execution.Env)
}
dst.Execution = &execCopy
}

// MCP (only scalar fields)
if src.MCP != nil {
mcpCopy := *src.MCP
dst.MCP = &mcpCopy
}

return dst
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] No unit tests cover the new cache behaviour — the mutation-isolation guarantee and the eviction/re-parse path are untested.

💡 Suggested test cases

Without tests, a future refactor could silently break the copy-on-return contract or leave the eviction path as dead code.

Three cases worth adding to behavior_defined_engine_test.go:

func TestParseEngineDefinitionFromJSON_CacheHitReturnsCopy(t *testing.T) {
    engineJSON := `{"id":"test-engine"}`
    first, _ := parseEngineDefinitionFromJSON(engineJSON)
    first.RuntimeID = "mutated"
    second, _ := parseEngineDefinitionFromJSON(engineJSON)
    if second.RuntimeID == "mutated" {
        t.Fatal("cache hit returned the same object; mutations leaked into cache")
    }
}

func TestParseEngineDefinitionFromJSON_EmptyJSON(t *testing.T) {
    def, err := parseEngineDefinitionFromJSON("")
    if def != nil || err != nil {
        t.Fatalf("expected nil, nil; got %v, %v", def, err)
    }
}

func TestParseEngineDefinitionFromJSON_InvalidJSON(t *testing.T) {
    _, err := parseEngineDefinitionFromJSON("{bad json")
    if err == nil {
        t.Fatal("expected error for invalid JSON")
    }
}

The eviction path (type-assertion failure) is harder to unit-test through the public API; a brief comment acknowledging it's defensive code is sufficient if full coverage isn't feasible.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 54898e7. Added behavior_defined_engine_cache_test.go with 10 tests covering all three of the suggested cases plus additional isolation cases:

  • TestParseEngineDefinitionFromJSON_EmptyJSON
  • TestParseEngineDefinitionFromJSON_InvalidJSON
  • TestParseEngineDefinitionFromJSON_CacheHitReturnsCopy (scalar mutation isolation)
  • TestParseEngineDefinitionFromJSON_CacheHitReturnsDeepCopy (map mutation isolation)
  • TestParseEngineDefinitionFromJSON_NonBuiltinNotCached
  • TestDeepCopyEngineDefinition_SlicesAreIndependent
  • TestDeepCopyEngineDefinition_BehaviorsAreIndependent
  • TestDeepCopyAny_NestedMapAndSlice

Added resetEngineDefinitionCacheForTest to ensure hermetic per-test state.


func (c *Compiler) registerNamedEngineDefinitionFromJSON(engineJSON string) error {
def, err := parseEngineDefinitionFromJSON(engineJSON)
if err != nil || !isEngineDefinitionForm(def) {
Expand Down
173 changes: 173 additions & 0 deletions pkg/workflow/behavior_defined_engine_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//go:build !integration

package workflow

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// resetEngineDefinitionCacheForTest clears the engine definition cache and
// built-in key set between tests to ensure hermetic behaviour.
func resetEngineDefinitionCacheForTest() {
engineDefinitionCache.Range(func(k, _ any) bool {
engineDefinitionCache.Delete(k)
return true
})
engineDefinitionBuiltinKeys.Range(func(k, _ any) bool {
engineDefinitionBuiltinKeys.Delete(k)
return true
})
}

func TestParseEngineDefinitionFromJSON_EmptyJSON(t *testing.T) {
def, err := parseEngineDefinitionFromJSON("")
assert.Nil(t, def)
assert.NoError(t, err)
}

func TestParseEngineDefinitionFromJSON_InvalidJSON(t *testing.T) {
_, err := parseEngineDefinitionFromJSON("{bad json")
assert.Error(t, err)
}

func TestParseEngineDefinitionFromJSON_NonObjectJSON(t *testing.T) {
// A JSON string (not an object) should return nil without error.
def, err := parseEngineDefinitionFromJSON(`"copilot"`)
assert.Nil(t, def)
assert.NoError(t, err)
}

func TestParseEngineDefinitionFromJSON_ValidObject(t *testing.T) {
const engineJSON = `{"id":"test-engine","display-name":"Test Engine"}`

def, err := parseEngineDefinitionFromJSON(engineJSON)
require.NoError(t, err)
require.NotNil(t, def)
assert.Equal(t, "test-engine", def.ID)
assert.Equal(t, "Test Engine", def.DisplayName)
// RuntimeID defaults to ID when omitted.
assert.Equal(t, "test-engine", def.RuntimeID)
}

func TestParseEngineDefinitionFromJSON_CacheHitReturnsCopy(t *testing.T) {
resetEngineDefinitionCacheForTest()
defer resetEngineDefinitionCacheForTest()

const engineJSON = `{"id":"test-engine","display-name":"Test Engine"}`

// Pre-seed the built-in key so the engine gets cached.
registerBuiltinEngineDefinitionJSON(engineJSON)

first, err := parseEngineDefinitionFromJSON(engineJSON)
require.NoError(t, err)
require.NotNil(t, first)

// Mutate a scalar field on the returned pointer.
first.RuntimeID = "mutated"

// A second call must return a fresh independent copy.
second, err := parseEngineDefinitionFromJSON(engineJSON)
require.NoError(t, err)
require.NotNil(t, second)
assert.NotEqual(t, "mutated", second.RuntimeID,
"cache hit returned the same object; scalar mutation leaked into cached state")
}

func TestParseEngineDefinitionFromJSON_CacheHitReturnsDeepCopy(t *testing.T) {
resetEngineDefinitionCacheForTest()
defer resetEngineDefinitionCacheForTest()

const engineJSON = `{"id":"test-engine","options":{"key":"value"}}`

registerBuiltinEngineDefinitionJSON(engineJSON)

first, err := parseEngineDefinitionFromJSON(engineJSON)
require.NoError(t, err)
require.NotNil(t, first)
require.NotNil(t, first.Options)

// Mutate the Options map on the returned definition.
first.Options["injected"] = "evil"

second, err := parseEngineDefinitionFromJSON(engineJSON)
require.NoError(t, err)
require.NotNil(t, second)
_, poisoned := second.Options["injected"]
assert.False(t, poisoned, "Options mutation leaked into cached state via shallow map copy")
}

func TestParseEngineDefinitionFromJSON_NonBuiltinNotCached(t *testing.T) {
resetEngineDefinitionCacheForTest()
defer resetEngineDefinitionCacheForTest()

const engineJSON = `{"id":"custom-engine"}`
// engineJSON is NOT registered as a built-in key.

_, err := parseEngineDefinitionFromJSON(engineJSON)
require.NoError(t, err)

// The cache must remain empty since this is not a known built-in.
found := false
engineDefinitionCache.Range(func(_, _ any) bool {
found = true
return false
})
assert.False(t, found, "non-builtin engine JSON was incorrectly added to the cache")
}

func TestDeepCopyEngineDefinition_SlicesAreIndependent(t *testing.T) {
src := EngineDefinition{
Models: ModelSelection{Supported: []string{"gpt-4", "gpt-3.5"}},
Auth: []AuthBinding{{Role: "api", Secret: "MY_SECRET"}},
}
dst := deepCopyEngineDefinition(src)

// Mutating src slices must not affect dst.
src.Models.Supported[0] = "changed"
src.Auth[0].Secret = "CHANGED"

assert.Equal(t, "gpt-4", dst.Models.Supported[0])
assert.Equal(t, "MY_SECRET", dst.Auth[0].Secret)
}

func TestDeepCopyEngineDefinition_BehaviorsAreIndependent(t *testing.T) {
src := EngineDefinition{
Behaviors: &EngineBehaviorDefinition{
SupportedEnvVarKeys: []string{"API_KEY"},
Execution: &EngineExecutionDefinition{
Args: []string{"--model", "gpt-4"},
Env: map[string]string{"FOO": "bar"},
},
},
}
dst := deepCopyEngineDefinition(src)

// Mutate src's nested reference types.
src.Behaviors.SupportedEnvVarKeys[0] = "CHANGED"
src.Behaviors.Execution.Args[0] = "CHANGED"
src.Behaviors.Execution.Env["FOO"] = "CHANGED"

require.NotNil(t, dst.Behaviors)
assert.Equal(t, "API_KEY", dst.Behaviors.SupportedEnvVarKeys[0])
require.NotNil(t, dst.Behaviors.Execution)
assert.Equal(t, "--model", dst.Behaviors.Execution.Args[0])
assert.Equal(t, "bar", dst.Behaviors.Execution.Env["FOO"])
}

func TestDeepCopyAny_NestedMapAndSlice(t *testing.T) {
src := map[string]any{
"nested": map[string]any{"key": "value"},
"list": []any{"a", "b"},
}
dst := deepCopyAny(src).(map[string]any)

// Mutate src; dst must be unaffected.
src["nested"].(map[string]any)["key"] = "mutated"
src["list"].([]any)[0] = "mutated"

assert.Equal(t, "value", dst["nested"].(map[string]any)["key"])
assert.Equal(t, "a", dst["list"].([]any)[0])
}
Loading
Loading