Skip to content

C#: byte storage

This bounded Phase 4 change specializes C# byte storage. Host operations, library exports, generated SDK interoperability and remaining target byte storage remain separate assignments.

The emitter recognizes underlying uint8, including byte, aliases, named elements, named slices and named arrays. Byte arrays and slice backing use native byte[]; generic slices and arrays retain object[]. Scalars keep the existing Go long representation. C# bytes are unsigned, so primitive reads widen directly to long; stores narrow with an explicit unchecked cast. Generic slice accessors box bytes as long, preserving interface and contract encoding behavior rather than exposing boxed host byte values.

Immutable headers retain backing identity, offset, length and capacity. Their Array backing holds byte[] or object[]; the BYTE_NIL hint preserves byte storage through nil views, empty spread append and zero-argument append. Nil remains distinct from allocated zero-length storage. Byte make and growth allocate the complete, physically zeroed capacity. The capacity rule remains max(needed, max(1, 2*cap)). Size addition widens before casts; host-limit checks precede allocation and mutation. Existing make validation and the int.MaxValue - 64 host element limit remain; available host memory also limits allocation. Host-limit rejection is an implementation fault, while source bounds and invalid make sizes remain source panics.

Native append and copy use overlap-safe Array.Copy, including both overlap directions. Spread append has no full-payload boxed temporary; individual arguments use primitive byte-array literals. String append writes directly to the selected native destination. Clear zeros only the selected view. Array assignment copies into existing native backing, preserving array/view aliases; array value copies and slice-to-array value conversion allocate independent native arrays. Array equality compares byte values and map-key helpers create immutable Latin-1 string snapshots. Named array and interface identity still comes from the existing type descriptors. Go strings retain one host character per source byte, and conversions copy NUL, high bytes and invalid UTF-8 without a text decoder.

Generated byte loads/stores use primitive bget/bset paths, including unsigned-index checks. Generic function-slice consumers, rune conversion and contract codecs use scalar accessors. Arrays, headers, struct fields, pointers, interfaces, closures and suspended frame fields remain ordinarily reachable through .NET GC. Element addresses, slice-to-array pointer conversions and source generics remain outside the supported language subset. No host-I/O ownership or retained-key lifecycle is introduced here.

Run these from the Goalchemy root. GOTOOLCHAIN selects the compiler’s Go toolchain; the loader and real native-Go oracle retain their pinned defaults.

Terminal window
GOTOOLCHAIN=go1.25.14 go run ./cmd/goalchemy spec validate
GOTOOLCHAIN=go1.25.14 go run ./cmd/goalchemy spec generate -check
GOTOOLCHAIN=go1.25.14 go test ./tests/contracts -count=1 -v
GOTOOLCHAIN=go1.25.14 go test ./internal/emit/... ./internal/driver ./internal/subset ./internal/specgen -count=1
GOTOOLCHAIN=go1.25.14 GOALCHEMY_TEST_TARGETS=csharp go test ./tests/language -count=1 -v
GOTOOLCHAIN=go1.25.14 GOALCHEMY_TEST_TARGETS=go,typescript,python,java,csharp,rust,c FIXTURE=byte_storage go test ./tests/language -count=1 -v
GOTOOLCHAIN=go1.25.14 go test ./tests/contracts -run TestCSharpByte -count=1 -v

All these checks passed without skips. The catalog remains 13 type contracts, 94 function contracts, seven targets and 502 generated files. The full C# language suite passed in 50.309 s, the contracts suite in 52.786 s, and the shared byte fixture across all seven targets in 32.854 s. Driver and subset checks passed in 18.627 s and 53.976 s respectively. These are observations, not timing requirements.

The default contracts include TestCSharpByteStorage, which compiles the common fixture and runs targets/csharp/tests/ByteStorageTest.cs against the emitted program/runtime. It asserts actual byte[] backing, full physical zeroing, native nil/empty behavior, offset/capacity identity, overlap, string copies, scalar long boxing, host-limit checks before mutation and retained generic storage. Reflection exercises emitted primitive zero/clone/set/equality and immutable key helpers, in addition to emitted-path checks; boxed storage cannot pass merely by printing equivalent observations.

TestCSharpByteGrowthNativeOracle always compares generated growth behavior with native Go. Before this change, C# raised NullReferenceException when reading grown spare capacity. The fixed observation is 4 255 128 3 0, followed by two zero lines. The earlier TypeScript assignment’s opt-in reproducer also passes for C# now:

Terminal window
GOTOOLCHAIN=go1.25.14 GOALCHEMY_BYTE_TARGETS=csharp go test ./tests/contracts -run TestTypeScriptByteGrowthNativeOracle -count=1 -v

The historical failing observation remains in out/csharp-byte-baseline.log and the original remaining-target baseline. The common native-Go differential byte_storage fixture covers named/alias types, arrays and struct copies, array assignment aliases, escaped pointers, interface aliases, closures, full slices, clear, detachment and arbitrary string bytes. The default TestCSharpByteValuesNativeOracle reuses the target-neutral java_byte_values fixture: array/interface map keys and immutable snapshots, valid and huge uint64 indexes, invalid make sizes, closures and cooperative suspension/frame storage. Full language regressions include non-byte slices, runes, maps, function slices, interfaces and cooperative scheduling.

Exact reproduction with the pinned .NET SDK 8.0.425:

Terminal window
sh targets/csharp/tests/byte_storage_bench.sh

The script compiles against the local .NET 8 reference assemblies and runs four fresh processes: native/boxed storage, each with 2/8 MiB input. The program warms the selected runtime path, measures managed heap after requested GC, fills source, makes destination, performs 16 copies and appends source to itself into a doubled-capacity buffer. Three buffers remain reachable through payload checks and GC.KeepAlive; exact native backing is four times input size. The boxed baseline uses the unchanged generic object[]/boxed long representation, including generic overlap temporaries.

Measured on Linux x86_64, kernel 6.8.0-87-generic, AMD Ryzen 7 6800H, 16 logical CPUs, .NET SDK 8.0.425/runtime 8.0.31:

Input Storage Exact native backing Managed heap delta Copy 16 times Append once
2 MiB byte[] 8,388,608 B 8,388,632 B 7.744 ms 3.969 ms
2 MiB Generic object[]/long — 117,432,624 B 342.528 ms 54.794 ms
8 MiB byte[] 33,554,432 B 33,554,456 B 30.643 ms 8.964 ms
8 MiB Generic object[]/long — 469,754,160 B 1,378.838 ms 242.191 ms

Array lengths establish one native byte per retained element. Managed heap results include runtime overhead and timings vary with GC, JIT, CPU and other processes; neither heap nor RSS thresholds are mandatory tests. The recorded measurement output is out/csharp-byte-memory.log. String conversion still allocates immutable host strings and copies; this is an in-memory runtime, not a streaming interface.

Python subsequently gained native byte-storage checks. Rust subsequently gained native byte-storage evidence. C subsequently gained native byte-storage evidence. Existing generic non-byte append growth can leave unset spare capacity; this change retains that separate gap. Pending host operations, completion retention, key/buffer lifetimes, cancellation, shutdown, library exports and generated SDK packages remain Phase 4/5/6 work. GCE002 for unavailable crypto/HTTP capabilities and GCE006 for unsupported library targets remain in place.