packet wire: write-side checks are debug only on every leg, and no write unwinds - #965
Draft
gafferongames wants to merge 6 commits into
Draft
gafferongames wants to merge 6 commits into
gafferongames wants to merge 6 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
assert.internal/codegen/dart/wstring.goemittedthrow ArgumentError('wstring length')andthrow ArgumentError('wstring null unit')— alive without--enable-asserts, where every other Dart write contract isassert(...). Both are nowassert(...).checkWrite<Name>predicate.internal/codegen/java/wstring.goemittedthrow new IllegalArgumentException(...)for the same two rules. The length range was already asserted incheckWrite<Name>, so the emitter now emits nothing; the interior-null rule joins it there (internal/codegen/java/functions.go,emitCheckScalar).&value.text[..value.text_length as usize](internal/codegen/rust/functions.go) panics on a negative or oversized length oncedebug_assert!is compiled out. The used length is clamped into[0, N]once and that length is what rides the wire.value.Text.AsSpan(0, value.TextLength)(internal/codegen/csharp/functions.go) throwsArgumentOutOfRangeExceptiononceDebug.Assertis 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 Javachar[N]wstring buffers hold exactly N units, so a length of N+1 would have become aRangeError/ArrayIndexOutOfBoundsExceptionin 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 inserialize_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.internal/codegen/{dart,java}/write_throw_test.gogrep every emitted write body forthrowand fail. Red on the tip's emitters, green here.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.dartnow 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-cspacket 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 forstring(N),bytes(N)orwstring(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