From 4a707603447f6a9ac2714c1c13ef3c3e75585cd6 Mon Sep 17 00:00:00 2001 From: "Norman Nunley, Jr" Date: Mon, 7 Sep 2026 14:20:32 -0400 Subject: [PATCH] fix(interop): preserve typed-array backing slices (#813) --- pkg/rt/generated.manifest | 4 +- pkg/rt/generated.sums | 2 +- pkg/vm/native_func.go | 13 ++++ pkg/vm/native_func_reflect_test.go | 109 +++++++++++++++++++++++++++++ test/typed_array_compat_test.go | 15 ++++ 5 files changed, 140 insertions(+), 3 deletions(-) diff --git a/pkg/rt/generated.manifest b/pkg/rt/generated.manifest index 8fc9907cd..18186190a 100644 --- a/pkg/rt/generated.manifest +++ b/pkg/rt/generated.manifest @@ -228,7 +228,7 @@ pkg/rt/core_compiled.lgb pkg/vm/map.go generator f8d314a14ca7c5c91055b7ec963d986 pkg/rt/core_compiled.lgb pkg/vm/meta_value.go generator 457b232d744935a2be249ab6d0a7b1029a6c7315086c0065fa6cb08e74bcead2 pkg/rt/core_compiled.lgb pkg/vm/multifn.go generator faabb5b0a048d17c744e7e8bed33087f7d671063f8b5e2142252674bf7576efe pkg/rt/core_compiled.lgb pkg/vm/namespace.go generator b847271519c2b7eeba5bee173e31b7d4dd4c49361476ded23baf4a6d2cd5942b -pkg/rt/core_compiled.lgb pkg/vm/native_func.go generator 63b68016e0875f5b2dc6d3615297d7923dc74bece0676cca728cb84a929afdc9 +pkg/rt/core_compiled.lgb pkg/vm/native_func.go generator 32527c009e38ed2f19724f9e4fe0035c56e4156e6c0e7310394b025689ee341a pkg/rt/core_compiled.lgb pkg/vm/native_func_reflect.go generator ded0face3302f48463a8021aa78e318cc1e9eaa7ccff488e19c592c930ddc133 pkg/rt/core_compiled.lgb pkg/vm/native_func_tinygo.go generator c03c3ec4a5656b532ccb03aade7c9416e31413837c603d9ca75e3555ae850b29 pkg/rt/core_compiled.lgb pkg/vm/nil.go generator 6bde1028333ea2286660e67121034d02e730c9d06db271b1ef93ebae5c09f177 @@ -546,7 +546,7 @@ pkg/rt/core_go_lowered/ pkg/vm/map.go generator f8d314a14ca7c5c91055b7ec963d9869 pkg/rt/core_go_lowered/ pkg/vm/meta_value.go generator 457b232d744935a2be249ab6d0a7b1029a6c7315086c0065fa6cb08e74bcead2 pkg/rt/core_go_lowered/ pkg/vm/multifn.go generator faabb5b0a048d17c744e7e8bed33087f7d671063f8b5e2142252674bf7576efe pkg/rt/core_go_lowered/ pkg/vm/namespace.go generator b847271519c2b7eeba5bee173e31b7d4dd4c49361476ded23baf4a6d2cd5942b -pkg/rt/core_go_lowered/ pkg/vm/native_func.go generator 63b68016e0875f5b2dc6d3615297d7923dc74bece0676cca728cb84a929afdc9 +pkg/rt/core_go_lowered/ pkg/vm/native_func.go generator 32527c009e38ed2f19724f9e4fe0035c56e4156e6c0e7310394b025689ee341a pkg/rt/core_go_lowered/ pkg/vm/native_func_reflect.go generator ded0face3302f48463a8021aa78e318cc1e9eaa7ccff488e19c592c930ddc133 pkg/rt/core_go_lowered/ pkg/vm/native_func_tinygo.go generator c03c3ec4a5656b532ccb03aade7c9416e31413837c603d9ca75e3555ae850b29 pkg/rt/core_go_lowered/ pkg/vm/nil.go generator 6bde1028333ea2286660e67121034d02e730c9d06db271b1ef93ebae5c09f177 diff --git a/pkg/rt/generated.sums b/pkg/rt/generated.sums index d547dc58c..fc817e8b0 100644 --- a/pkg/rt/generated.sums +++ b/pkg/rt/generated.sums @@ -2,4 +2,4 @@ # Digest of generated.manifest: generator input provenance plus declared # file-output readiness records. Input checks and output-readiness queries # interpret those record kinds separately. -c774c0ded555af88679076c9a86347ae597dc01354f727fbf2fed3421dce0315 +909b16ed145ab5e7d9a04d1ed92ac7e7d8a322c771632df0d7adcd5c84589827 diff --git a/pkg/vm/native_func.go b/pkg/vm/native_func.go index 1f79b3bbb..11a5ea101 100644 --- a/pkg/vm/native_func.go +++ b/pkg/vm/native_func.go @@ -114,6 +114,19 @@ func boxArgForReflect(v Value, target reflect.Type) (reflect.Value, error) { var convErr error if target.Kind() == reflect.Slice || target.Kind() == reflect.Array { + // TypedArray is the mutable array boundary: when its native backing + // slice already satisfies the Go parameter, pass that slice through so + // writes made by APIs such as io.Reader.Read remain visible to let-go. + // Incompatible element types and fixed arrays still use the ordinary + // per-element conversion below, as do persistent collections. + if target.Kind() == reflect.Slice { + if arr, ok := v.(*TypedArray); ok && arr != nil { + backing := reflect.ValueOf(arr.Unbox()) + if usableAsReflectArg(backing, target) { + return backing, nil + } + } + } if sq, ok := v.(Sequable); ok { out := reflect.New(target).Elem() err := unboxSliceInto(out, sq.Seq()) diff --git a/pkg/vm/native_func_reflect_test.go b/pkg/vm/native_func_reflect_test.go index cbf5bcf0f..ab4335bf7 100644 --- a/pkg/vm/native_func_reflect_test.go +++ b/pkg/vm/native_func_reflect_test.go @@ -88,6 +88,115 @@ func TestBoxReflectFuncMultiReturn(t *testing.T) { } } +func TestBoxReflectFuncTypedArrayPreservesBackingSlice(t *testing.T) { + invoke := func(t *testing.T, fn any, arg Value) { + t.Helper() + boxed, err := NativeFnType.Box(fn) + if err != nil { + t.Fatalf("Box: %v", err) + } + if _, err := boxed.(*NativeFn).proxy([]Value{arg}); err != nil { + t.Fatalf("proxy: %v", err) + } + } + + t.Run("byte array", func(t *testing.T) { + backing := []byte{1, 2} + arr := NewByteArrayFrom(backing) + sameBacking := false + invoke(t, func(xs []byte) { + sameBacking = &xs[0] == &backing[0] + xs[0] = 9 + }, arr) + if !sameBacking || backing[0] != 9 || arr.Get(0) != Int(9) { + t.Fatalf("byte-array backing was copied: same=%v backing=%v value=%v", sameBacking, backing, arr.Get(0)) + } + }) + + t.Run("int array", func(t *testing.T) { + backing := []int64{1, 2} + arr := NewIntArrayFrom(backing) + sameBacking := false + invoke(t, func(xs []int64) { + sameBacking = &xs[0] == &backing[0] + xs[0] = 9 + }, arr) + if !sameBacking || backing[0] != 9 || arr.Get(0) != Int(9) { + t.Fatalf("int-array backing was copied: same=%v backing=%v value=%v", sameBacking, backing, arr.Get(0)) + } + }) + + t.Run("double array", func(t *testing.T) { + backing := []float64{1, 2} + arr := NewFloatArrayFrom(backing) + sameBacking := false + invoke(t, func(xs []float64) { + sameBacking = &xs[0] == &backing[0] + xs[0] = 9.5 + }, arr) + if !sameBacking || backing[0] != 9.5 || arr.Get(0) != Float(9.5) { + t.Fatalf("double-array backing was copied: same=%v backing=%v value=%v", sameBacking, backing, arr.Get(0)) + } + }) + + t.Run("object array", func(t *testing.T) { + backing := []Value{Int(1), Int(2)} + arr := NewObjectArrayFrom(backing) + sameBacking := false + invoke(t, func(xs []Value) { + sameBacking = &xs[0] == &backing[0] + xs[0] = String("changed") + }, arr) + if !sameBacking || backing[0] != String("changed") || arr.Get(0) != String("changed") { + t.Fatalf("object-array backing was copied: same=%v backing=%v value=%v", sameBacking, backing, arr.Get(0)) + } + }) + + t.Run("compatible named slice", func(t *testing.T) { + type namedBytes []byte + backing := []byte{1, 2} + arr := NewByteArrayFrom(backing) + sameBacking := false + invoke(t, func(xs namedBytes) { + sameBacking = &xs[0] == &backing[0] + xs[0] = 9 + }, arr) + if !sameBacking || arr.Get(0) != Int(9) { + t.Fatalf("named slice lost typed-array backing: same=%v value=%v", sameBacking, arr.Get(0)) + } + }) +} + +func TestBoxReflectFuncPersistentCollectionsStillConvertToSlices(t *testing.T) { + for _, tt := range []struct { + name string + arg Value + }{ + {name: "vector", arg: NewArrayVector([]Value{Int(1), Int(2)})}, + {name: "list", arg: NewList([]Value{Int(1), Int(2)})}, + } { + t.Run(tt.name, func(t *testing.T) { + boxed, err := NativeFnType.Box(func(xs []int) int { + xs[0] = 9 + return xs[0] + xs[1] + }) + if err != nil { + t.Fatalf("Box: %v", err) + } + got, err := boxed.(*NativeFn).proxy([]Value{tt.arg}) + if err != nil { + t.Fatalf("proxy: %v", err) + } + if got != Int(11) { + t.Fatalf("converted slice result = %v, want 11", got) + } + if first := tt.arg.(Sequable).Seq().First(); first != Int(1) { + t.Fatalf("persistent source was mutated through copied []int: %v", first) + } + }) + } +} + // func() error must keep returning the error as a VALUE rather than throwing. // Every reflect-boxed Close/Write/Flush has this shape, so peeling here would // silently change how a large amount of existing interop behaves. The peel diff --git a/test/typed_array_compat_test.go b/test/typed_array_compat_test.go index 8f53844d2..017e3d02d 100644 --- a/test/typed_array_compat_test.go +++ b/test/typed_array_compat_test.go @@ -24,6 +24,21 @@ func TestTypedArraysAcceptEmptySeqableInputs(t *testing.T) { } } +func TestByteArrayMutationSurvivesGoInterop(t *testing.T) { + got, err := evalCoreCompat(`(do + (require '[io :as io]) + (let [r (io/string-reader "ABC") + b (byte-array 3) + n (.Read r b)] + [n (vec b)]))`) + if err != nil { + t.Fatal(err) + } + if got.String() != "[3 [65 66 67]]" { + t.Fatalf("Go mutation must remain visible through the typed array: got %s", got) + } +} + func TestContainsRejectsUnsupportedCollectionsAndArrayKeys(t *testing.T) { cases := map[string]string{ "array nil key": `(contains? (int-array [0 1 2]) nil)`,