Skip to content

binary: the unit goes in the name, and a short window stops being silent - #36

Merged
alii merged 1 commit into
masterfrom
binary-slice-units
Aug 13, 2026
Merged

binary: the unit goes in the name, and a short window stops being silent#36
alii merged 1 commit into
masterfrom
binary-slice-units

Conversation

@alii

@alii alii commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

binary.slice indexed in BITS while byte_size answered in BYTES, so a byte-indexed caller got a short window and no error until offset+len passed 8*size. slice_bytes existed and was byte-indexed, but returned a bare Binary and collapsed every out-of-range case to <<>>. Two silences — and fixing either alone steers callers straight into the other, which is why they are one change.

Fixes T-208 and T-214.

slice is deleted, not renamed

T-208 suggested renaming it to slice_bits and adding a byte-indexed slice alongside. That is the worst option available: every existing binary.slice call keeps compiling and silently means something eight times different.

Deleting the name is what produces a compiler error on the old spelling — the property this project accepts as proof. It cost two call sites, because almost nobody used the bit form: 24 of 26 in-tree slice calls were already slice_bytes.

slice_bits / slice_bytes / drop_bytes each carry the unit in the name. slice_bytes returns Result(Binary, Nil).

Unit types were rejected with an argument, not on cost

Scarlet can express pub type Bytes { Bytes(Int) }Radix is that shape. It cannot make it pay.

A newtype at the leaf checks nothing: for the type to protect anything it must reach the source of the quantity, and every source is a bare Int (byte_size, bit_size, byte_at, index_of -> Option(Int)). Leave those and the caller writes Bytes(byte_size(b) - i - 1) — the ×8 mistake lives inside that wrapping expression exactly as before. Wrap the sources too and you need +, - and comparison on Bytes, and Scarlet has no operator overloading. Every site touched here does arithmetic on the offset.

Filed as T-286, including the residual hole: nothing stops a bit count being passed to slice_bytes.

drop_bytes takes a position, not a length

slice_bytes(b, i + 1, byte_size(b) - i - 1) was 7 of the 24 call sites, and that arithmetic is itself where an off-by-one lives. drop_bytes(b, at) is total honestly: there is no requested size for the answer to fall short of, so past the end the remainder genuinely is <<>>.

No take_bytes sibling, deliberately — clamping a requested length is a short read, which is the silence this PR removes.

Plants, both directions, watched

plant result
re-add slice as a bit-indexed alias new test FAILS rc=101, expected 'al check' to REJECT
restore T-214's silence wearing a Result (Err(Nil)Ok(<<>>)) FAILS rc=101 on the Err(Nil) assertions

Before/after on the T-214 case, with the in-range control on the same code path:

BEFORE                    AFTER
<<>>       (4, 2)         Err(Nil)      <- one byte over, was silent
<<2, 3>>   (1, 2)         Ok(<<2, 3>>)  <- non-zero control, window unchanged

One assertion was tightened after being written: check_rejects(…, "slice") would have passed on a diagnostic about slice_bits, since bare slice is a substring.

Gates

fmt 0 · clippy -- -D warnings 0 · test --workspace 0 · gen-editor-syntax --check 0 · SCARLET_GC_STRESS=1 cargo test --workspace 0.

dylint, hawk and ts-corpus are red — and identically red on stashed master. The boundary experiment was run rather than argued from the file list: dylint is the HeadFlags finding (T-278/T-279, in flight), hawk is 3 unnecessary_restricted_visibility in vm/mailbox.rs and vm/supervision.rs, ts-corpus is 2 parse errors in examples/supervision.scrl and examples/chat.scrl. None of those files are in this diff, and every .scrl file that is parses clean under tree-sitter.

One golden moved, four lines of 67

wire_format.stdout — the T-214 fix itself (past the end: <<>>Err(Nil)) plus three new demo lines. Not a type-id shift, so line-for-line rather than a shift proof. The field name: block restructured into a match produces byte-identical output.

Known residual

Four call sites now carry an Err(Nil) arm argued unreachable in a comment rather than by a typehttp.path, http.read_body, http_client's head_bytes, and drop_bytes' own tail. In each the bound comes from index_of or byte_size(buf) - consumed. That is exactly the shape T-286 would close.

A split_at_bytes(b, at) (Binary, Binary) primitive would collapse the index_of + prefix + suffix idiom at ~6 sites and remove three of those four arms. Not built — a third new name is outside what T-208 asked for.

binary.slice indexed in BITS while byte_size answered in BYTES, so a
byte-indexed caller got a short window and no error until offset+len passed
8*size. slice_bytes existed and was byte-indexed but returned a bare Binary
and collapsed every out-of-range case to <<>>. Two silences, and fixing
either alone steers callers into the other, so they are one change.

slice is DELETED rather than renamed. T-208 suggested renaming it to
slice_bits and adding a byte-indexed slice alongside; that is the worst
option on the table, because every existing call keeps compiling and
silently means something eight times different. Deleting the name is what
produces a compiler error on the old spelling. It cost two call sites:
24 of 26 in-tree slice calls were already slice_bytes.

slice_bits / slice_bytes / drop_bytes each carry the unit in the name.
slice_bytes is Result(Binary, Nil).

Unit types were considered and rejected with an argument, not on cost.
Scarlet can express `pub type Bytes { Bytes(Int) }` - Radix is that shape -
but cannot make it pay. A newtype at the leaf checks nothing: every source
of the quantity is a bare Int (byte_size, bit_size, byte_at, index_of), so
the caller writes Bytes(byte_size(b) - i - 1) and the x8 mistake lives
inside that expression exactly as before. Wrapping the sources too needs
+, - and comparison on Bytes, and Scarlet has no operator overloading;
every site touched here does arithmetic on the offset. T-286.

drop_bytes is new and takes a POSITION, not a length. That is what makes it
honestly total: there is no requested size for the answer to fall short of,
so past the end the remainder genuinely is <<>>. No take_bytes sibling on
purpose - clamping a requested length is a short read, which is the silence
this commit removes.

Plants, both directions, both watched. Re-adding slice as a bit-indexed
alias reds the new test rc=101. Restoring T-214's silence wearing a Result
(Err(Nil) -> Ok(<<>>)) reds it on the Err assertions. One assertion was
tightened after writing it: check_rejects(.., "slice") would have passed on
a diagnostic about slice_bits, since bare slice is a substring.

Four call sites now carry an Err(Nil) arm argued unreachable in a comment
rather than by a type - http.path, http.read_body, http_client's head_bytes,
drop_bytes' tail. That is the shape T-286 would close.

wire_format's parse_payload on 'a b=1' computed a negative-length window; it
used to yield <<>> and fail later in parse_int with the same message, and
now fails at the window. Same outcome, honest cause.

fmt 0, clippy -D warnings 0, test --workspace 0, gen-editor-syntax 0,
SCARLET_GC_STRESS=1 0. dylint/hawk/ts-corpus red identically on stashed
master - boundary experiment run, none of those files are in this diff.
@alii
alii force-pushed the binary-slice-units branch from b59d1a3 to 07753e0 Compare August 13, 2026 04:47
@alii
alii merged commit 383353b into master Aug 13, 2026
4 checks passed
alii added a commit that referenced this pull request Aug 13, 2026
#36 and #33 each merge clean and the merged tree does not build: 14 calls
expect a bare Binary. Fourteen unwraps would restore exactly the silence #36
exists to remove, with a Result in the signature to make it look handled, so
every site was classified instead.

FIVE were the tail, slice_bytes(b, at, byte_size(b) - at). drop_bytes already
names that operation and is honestly total, so they carry no Err arm at all:
url.split_bracketed's rest, url.split_at_colon's port_text, url.port_of's
digits, client.read_head's rest, http_response's wire_loop.

ONE takes master's own idiom, result.then(slice_bytes(..), to_string) or '?',
byte-identical to what #36 did to http_parse.scrl's twin line.

ONE is a genuine error and is now visible. http_response's "consumed threads
to the body" asserts a 5-byte window against a Content-Length of 5, so a
consumed that does not thread there has to print as an error rather than as an
empty window.

SEVEN carry an Err arm argued unreachable, each naming the bound it rests on:
an index_of hit, first_of's clamp to size, the leading-[ guard, the
byte_size >= n guard, int.min. That is an argument in a comment rather than a
type, which is T-286. Six of the seven fall through to a loud error anyway
(UnsupportedScheme, EmptyHost, MalformedResponse); target_of's does not, and
says so.

url.scrl's malformed-URL cases turn out to be rejected before any window is
computed — scheme_sep, split_bracketed and port_of all fail first — so none of
its 8 became a new UrlError variant. A variant nothing can construct is worse
than the comment it replaces.

Not done here: those 7 are all one operation, "cut b at a position something
else already proved is inside b". drop_bytes names the tail half and the head
half has no name. T-309 carries the site list and the argument that
split_at_bytes is total for drop_bytes's own reason, and is not the take_bytes
#36 refused — window_bytes(b, from, to) is, which is why it is not that.

dis.rs's pool ceiling re-measured on the merged tree: 714 entries, not the 712
the comment claimed. The 1000 ceiling is unchanged and not close.

Plants, all watched red and restored. #33's load-bearing one first: dropping
the whitespace-before-colon disjunct in vm/http.rs reds http_parse AND
http_response from that one edit, rc=101 — the response head parser is still
literally the request head parser after a 14-site edit. Then two of mine.
Truncating the framed fixture prints "OUT OF RANGE" in place of a short
window, rc=101, so the genuine error reaches the caller. Weakening
read_exactly's guard to >= n - 1 prints Err MalformedResponse(BadFraming) in
place of a truncated body, rc=101, so the argued-unreachable arm is live and
loud when its bound is wrong.

fmt 0, clippy -D warnings 0, test --workspace 0, gen-editor-syntax --check 0,
SCARLET_GC_STRESS=1 0, dylint --all 0 with the cache cleared and sources
touched (0 findings), hawk check -D warnings 0 (0 findings).

Filed alongside: T-309 (split_at_bytes), T-310 (h1.scrl's HeadFlags still
carries two connection bools where the Rust side is one ConnTokens).
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