#1263·eino

compose: fan-in merge panics nondeterministically when a predecessor returns (nil, nil)

Author: Dragonzz27Created Sep 8, 2026Updated Sep 8, 2026

Summary

When a node in a fan-in returns (nil, nil), mergeValues (compose/values_merge.go:40-41) receives the untyped nil among the values to merge. Channel Values are stored in a Go map, so iteration order is random:

  • when the nil lands at vs[0]: reflect.ValueOf(vs[0]).Type() panics with reflect: call of reflect.Value.Type on zero Value (re-panicked out of Invoke via graph_run.go:112);
  • otherwise: the type-specific merge func fails with e.g. (values merge map) field type mismatch. expected: 'map[string]interface {}', got: '<nil>'.

Same compiled graph, same input — panic or error depending on map iteration order.

To Reproduce

go
func TestNilFanIn(t *testing.T) {
    for i := 0; i < 30; i++ {
        g := compose.NewGraph[string, any]()
        g.AddLambdaNode("nilnode", compose.InvokableLambda(func(ctx context.Context, in string) (any, error) {
            return nil, nil
        }))
        g.AddLambdaNode("mapnode", compose.InvokableLambda(func(ctx context.Context, in string) (any, error) {
            return map[string]any{"k": "v"}, nil
        }))
        g.AddLambdaNode("sink", compose.InvokableLambda(func(ctx context.Context, in any) (any, error) {
            return in, nil
        }))
        g.AddEdge(compose.START, "nilnode")
        g.AddEdge(compose.START, "mapnode")
        g.AddEdge("nilnode", "sink")
        g.AddEdge("mapnode", "sink")
        g.AddEdge("sink", compose.END)
        r, _ := g.Compile(context.Background())
        _, _ = r.Invoke(context.Background(), "x") // panics on some iterations
    }
}

Unit-level, both forms reproduce deterministically:

go
mergeValues([]any{nil, map[string]any{"k": "v"}}, nil) // panic
mergeValues([]any{map[string]any{"k": "v"}, nil}, nil) // field type mismatch error
mergeValues([]any{nil, nil}, nil)                        // panic

Expected behavior

An untyped nil output carries no value: the merge should deterministically equal the merge of the remaining values (all-nil merges to nil), instead of panicking or erroring at random.

Note

This is distinct from the runtime edge type check: an any-typed predecessor's nil output flowing into a map[string]any-typed successor fails the assignableTypeMay edge check with a clear, deterministic error — that behavior is intentional and unchanged. This issue is specifically about the fan-in merge of values that legitimately reached the merge step.

Version

Current main (9d983b36). I have a fix with unit + graph-level regression tests ready and will open a PR shortly.