Skip to content

packet wire: write-side checks are debug only on every leg, and no write unwinds - #965

Draft
gafferongames wants to merge 6 commits into
fixed-table-formfrom
rowan/packet-write-tiers
Draft

gafferongames wants to merge 6 commits into
fixed-table-formfrom
rowan/packet-write-tiers

Conversation

@gafferongames

@gafferongames gafferongames commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

A nine-leg read of SPEC §5's tiers against the emitters found four write paths that survive a release build, against §5's own sentence: "No target panics and none throws: Elixir's raise is the only unwinding path in the nine."

The four fixes

  1. Dart wstring throws → assert. internal/codegen/dart/wstring.go emitted throw ArgumentError('wstring length') and throw ArgumentError('wstring null unit') — alive without --enable-asserts, where every other Dart write contract is assert(...). Both are now assert(...).
  2. Java wstring throws → the dormant checkWrite<Name> predicate. internal/codegen/java/wstring.go emitted throw new IllegalArgumentException(...) for the same two rules. The length range was already asserted in checkWrite<Name>, so the emitter now emits nothing; the interior-null rule joins it there (internal/codegen/java/functions.go, emitCheckScalar).
  3. Rust's write no longer panics in release. &value.text[..value.text_length as usize] (internal/codegen/rust/functions.go) panics on a negative or oversized length once debug_assert! is compiled out. The used length is clamped into [0, N] once and that length is what rides the wire.
  4. C#'s write no longer throws in release. value.Text.AsSpan(0, value.TextLength) (internal/codegen/csharp/functions.go) throws ArgumentOutOfRangeException once Debug.Assert is gone. Same clamp, same single use for both the length field and the payload slice.

Fixes 1 and 2 would have opened the same hole they closed: the Dart Uint16List(N) and the Java char[N] wstring buffers hold exactly N units, so a length of N+1 would have become a RangeError / ArrayIndexOutOfBoundsException in a build without asserts. Both legs clamp too, so all four write the clamped length.

Also in scope: Rust and C# gained the write-side interior-null debug check for string(N) that cpp and c have carried in serialize_assert, so §4.7's "writes assert per §5" is now true on those legs.

SPEC

docs/SPEC.md §5 gains the rule the clamp follows: a debug-only check obliges the release path to be total, so in a release build a write with an out-of-contract length writes the clamped length — the bytes of the clamped write, byte for byte, never a trap — while the debug assert on the declared contract is unchanged and fires first.

Gates

  • go test ./internal/codegen/{dart,java,rust,csharp}/ ./compiler/ -run 'Write|Throw|Tier|Packet|Golden' — green.
  • New generator tests internal/codegen/{dart,java}/write_throw_test.go grep every emitted write body for throw and fail. Red on the tip's emitters, green here.
  • Goldens re-pinned through go test ./internal/goldens -update -run TestGolden: packet-wide/{dart,java,cs,rust}, rust/*, cs/*, wide/cs. Every hunk read.
  • gofmt -l . empty.
  • test/packet-wide/dart/main.dart now probes the contracts only under --enable-asserts (the compiled-exe run of the same file has no asserts) and, without them, checks that the length-N+1 write equals the clamped write bit for bit.

Not yet run in this window

The dart/java/rust/cs packet make gates (test-dart, test-java, test-rust, test-cs packet subsets) and the C# Release runtime test of the clamp were not run here — toolchain builds exceed the window. Draft until they are green.

Follow-up in fb344666: a DeepSeek read of this PR against SPEC §5 found the fix half-applied — the Rust and C# wstring(N) writers still unwound in release, and C and C++ clamped nowhere for string(N), bytes(N) or wstring(N). All four now clamp the used length into [0, N], with a generator test per leg (each red on the emitter this PR left behind), and §5's sentence now names all nine truthfully.

🤖 Generated with Claude Code

gafferongames and others added 5 commits September 11, 2026 08:31
…ite unwinds

A nine-leg read of SPEC §5's tiers against the emitters found four write
paths that survive a release build, where §5 says "No target panics and
none throws: Elixir's raise is the only unwinding path in the nine".

Dart and Java emitted `throw ArgumentError(...)` / `throw new
IllegalArgumentException(...)` for the two wstring writer rules (§4.12) —
alive without --enable-asserts and without -ea, where every other write
contract on those legs is `assert(...)` and the dormant
`assert checkWrite<Name>(...)` predicate. Both now ride the leg's own
debug idiom: Dart asserts in place, Java's length range was already in
checkWrite and the interior-null rule joins it there.

Rust and C# had no throw of their own, but their release path DID unwind:
`&value.text[..value.text_length as usize]` panics and
`value.Text.AsSpan(0, value.TextLength)` throws
ArgumentOutOfRangeException once the debug contract is compiled out. The
same hole was about to open on Dart and Java, whose wstring buffers hold
exactly N units. All four now clamp the used length into [0, N] once and
write THAT length, so a release write of an out-of-contract length
produces the bytes of the clamped write, byte for byte, and never a trap.
The debug assert on the declared contract is unchanged and fires first.

Rust and C# also gained the write-side interior-null debug check for
string(N) that cpp and c have carried in serialize_assert, so §4.7's
"writes assert per §5" is now true on those legs too.

Generator tests per leg grep every emitted write body for `throw` and
fail; both are red on the tip's emitters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…p too

A DeepSeek read of #965 against SPEC §5, verified by triage, found the fix
half-applied. §5 now says "the generated writers therefore clamp the used
length into [0, N] once" — but only string(N)/bytes(N) on Rust and C# and the
wstring on Dart and Java actually did.

Rust's wstring writer still sliced `&value.text[..length as usize]` behind a
lone debug_assert!, and C#'s still looped to the raw value.TextLength while
indexing a char[N]: in release both unwind, the one thing §5 says no leg does
outside Elixir. Both now clamp the used length into [0, N] once and write THAT
length; the debug assert on the declared contract is unchanged and fires first.

C and C++ clamped nowhere — not for string(N), bytes(N) or wstring(N). They
have no bounds check to trip, so release walked off the buffer and the WIRE
became whatever the caller's memory held: no trap, and no deterministic bytes
either, which is the other half of the rule. Both legs now fold the used length
into a `clamped_length` before the loop and the copy, serialize_assert
unchanged.

§5's sentence was unqualified and is now true of all nine by name: six clamp
(Rust, C#, Java, Dart, C, C++), and the length never reaches the slice
out-of-contract on the other three — Go latches ErrValueOutOfRange in every
build, Elixir raises, and JavaScript's checked writer refuses while subarray
clamps by the language's own definition.

Generator tests per leg pin the clamp in the emitted writer; all four are red
on the emitters #965 left behind. Goldens re-pinned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant