Skip to content

test: add a forceUTF16 harness helper; the slice-off-a-wide-char idiom yields 8-bit strings - #39478

Open
robobun wants to merge 1 commit into
mainfrom
farm/55f86fc5/utf16-test-helper
Open

test: add a forceUTF16 harness helper; the slice-off-a-wide-char idiom yields 8-bit strings#39478
robobun wants to merge 1 commit into
mainfrom
farm/55f86fc5/utf16-test-helper

Conversation

@robobun

@robobun robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Test-only follow-up to #39460.

Problem

  • Five tests force a string into 16-bit storage with (s + "\u0100").slice(0, -1) (or the same with an emoji) so they can exercise an API's 16-bit code path on Latin-1 content: test/js/node/buffer.test.js (the hex length sweep, the invalid-character sweep and the buf.write "16-bit string path" case), test/js/web/fetch/headers.test.ts (lowercaseHeaderNameSIMD "16-bit: ... across lengths and alignments"), test/js/bun/util/sliceAnsi.test.ts ("UTF-16 ASCII fast path") and both "encoding equivalence" tests in test/js/bun/util/sliceAnsi-fuzz.test.ts.
  • That expression yields an 8-bit string. s + "\u0100" is an unresolved rope, and slicing a range that lies entirely inside its first fiber returns that fiber, which is the original 8-bit s. Checked with jscInternals.isUTF16String on a debug build: false at every length in the buffer sweep (15 to 1024 pairs) and for 0 to 160 characters of the headers alphabet (table below).
  • So these "16-bit path" assertions were running the 8-bit path a second time. For the hex decoder that buffer: decode hex from the low byte of each UTF-16 code unit like node #39460 changed, the only tests that reached DecodeHex16Impl were the new ones that contain genuine wide units; the length sweep never did.

Fix

  • test/harness.ts: forceUTF16(s) builds the string through a utf16le round trip (Buffer.from(s, "utf16le").toString("utf16le"), which Bun materializes as a 16-bit string) and throws if the result is not 16-bit, so a future change to how such strings are materialized fails the test instead of silently dropping the coverage again. Strings shorter than 2 code units are returned as-is: JSC interns the empty string and single Latin-1 characters as 8-bit, so they cannot be forced.
  • The five call sites use the helper; the local toUTF16 / to16 lambdas and the comments describing the old trick are removed.
  • Verified with the debug build: test/js/node/buffer.test.js (645 pass), headers.test.ts, sliceAnsi.test.ts, sliceAnsi-fuzz.test.ts (313 pass across the three) all pass with real 16-bit input, so the 16-bit kernels they now reach agree with the 8-bit ones on this content.
  • Other tests that mention forcing 16-bit storage (escapeHTML, stringWidth, stripANSI, yaml, the ucs2 fill case in buffer.test.js) keep the wide character in the string, so they are genuinely 16-bit and are unchanged.

Background

  • JSC stores a string as one byte per character (8-bit, Latin-1) when every character is below U+0100, otherwise as UTF-16 code units (16-bit). Several of Bun's SIMD kernels (hex decode, header-name lowercasing, sliceAnsi) have a separate implementation per storage width, and an all-ASCII string can legitimately arrive in 16-bit storage (for example a substring of a string that also contained non-Latin-1 text), so tests want to run both.
  • a + b on strings produces a rope (a lazily concatenated string with a and b as fibers). Substring of a rope that falls inside a single fiber returns that fiber directly rather than flattening the rope, which is why slicing the appended character back off hands back the original 8-bit string.
  • bun:internal-for-testing's jscInternals.isUTF16String (exposed by the harness as String.prototype.isUTF16 and the toBeUTF16String matcher) reports the actual storage width; the helper uses it as its postcondition.
Storage width of the old idiom vs the helper (debug build)

chars is the length of the ASCII input; each column is isUTF16String(...) of the result.

chars (s + "\u0100").slice(0, -1) Buffer.from(s, "utf16le").toString("utf16le")
0 false false
1 false false
2 false true
30, 32, 34 false true
62, 64, 66 false true
128, 256, 512, 2048 false true

("hello world" + "\u{1F600}").slice(0, 11) and (s + "\u{1F600}").slice(0, -2) (the sliceAnsi variants) are also false. Slicing a rope that has been resolved first (for example after a charCodeAt) does stay 16-bit, which is presumably how the idiom was originally observed to work.


no test proof · iteration 0 · Platform-specific test-only change; deferring to CI.

…yields 8-bit strings

Several tests forced 16-bit string storage with
(s + "\u0100").slice(0, -1) or the same with an emoji. JSC serves that
slice straight from the rope's first fiber, which is the original 8-bit
string, so these "16-bit path" tests were running the 8-bit path a
second time. forceUTF16 builds the string through a utf16le round trip
and asserts it really is 16-bit; the five call sites now use it.
@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Test-only change (no src/ diff), split out of the review of #39460.

Verified on a debug build that the old idiom returns an 8-bit string at every length used by these tests and that forceUTF16 returns a 16-bit one (table in the PR body), and that all four test files pass with genuine 16-bit input: buffer.test.js 645 pass, headers.test.ts + sliceAnsi.test.ts + sliceAnsi-fuzz.test.ts 313 pass.

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fa78b1be-d6d0-4eb6-9c19-8799da422d10

📥 Commits

Reviewing files that changed from the base of the PR and between 10a6115 and 816f893.

📒 Files selected for processing (5)
  • test/harness.ts
  • test/js/bun/util/sliceAnsi-fuzz.test.ts
  • test/js/bun/util/sliceAnsi.test.ts
  • test/js/node/buffer.test.js
  • test/js/web/fetch/headers.test.ts

Included review availability: Your plan includes up to 5 reviews per rolling hour; 1 remains after this review.


Walkthrough

Changes

The test harness adds forceUTF16, which validates 16-bit JSC string storage. Related sliceAnsi, buffer, and fetch headers tests now use this shared helper instead of local conversion techniques.

UTF-16 Test Inputs

Layer / File(s) Summary
Validated UTF-16 helper
test/harness.ts
Adds forceUTF16(s: string): string with UTF-16LE round-tripping, short-string handling, storage validation, and failure reporting.
Test migration
test/js/bun/util/*, test/js/node/buffer.test.js, test/js/web/fetch/headers.test.ts
Replaces surrogate-based and local UTF-16 conversion helpers with forceUTF16 while preserving existing test coverage and assertions.

Suggested reviewers: jarred-sumner, cirospaciari

Merge Risk: ⚪ Minimal · up to 816f8

This test-only change makes the UTF-16 coverage helper explicit and verifies the affected tests with real 16-bit inputs; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the new forceUTF16 helper and the string-storage bug it corrects.
Description check ✅ Passed The description explains the problem, fix, affected tests, and verification results, although it does not use the template headings.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Nothing to address from the automated review. The Buildkite build is still running; the failing claude-find-issues job is the action failing to start (it fails the same way on unrelated PRs, e.g. #39453) and is not related to this change.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — test-only change replacing a broken 16-bit-string idiom with a verified harness helper.

Checked that the utf16le round-trip preserves content exactly and that the length < 2 early return is harmless at every call site (headers loop len 0/1 is below SIMD stride; buffer sweep starts at 30 chars; sliceAnsi inputs are ≥4). The isUTF16() postcondition means future changes to Buffer's string materialization would fail loudly rather than silently drop coverage again.

Extended reasoning...

Overview

Adds forceUTF16(s) to test/harness.ts and replaces the (s + "\u0100").slice(0, -1) idiom (and its emoji variant) at five call sites across buffer.test.js, headers.test.ts, sliceAnsi.test.ts, and sliceAnsi-fuzz.test.ts. The old idiom returned the original 8-bit fiber of an unresolved rope, so the "16-bit path" tests were re-running the 8-bit path. The new helper round-trips through Buffer.from(s, "utf16le").toString("utf16le") and asserts isUTF16() on the result.

Security risks

None. Test-only; no production code, no external I/O, no new dependencies.

Level of scrutiny

Low. This is a mechanical test-quality fix: a shared helper replacing local lambdas that were provably not doing what their comments claimed. The helper has a self-checking postcondition, so if the utf16le materialization behavior ever changes the tests fail rather than silently losing coverage.

Other factors

  • Verified the round-trip is content-preserving for arbitrary JS strings (per-code-unit LE encode/decode), so out === s semantically at every call site.
  • The length < 2 escape hatch is correct (JSC interns empty/single-Latin-1 as 8-bit) and no call site depends on len 0/1 being 16-bit — the headers sweep at len 0/1 is below any vector width, and all other inputs are ≥4 chars.
  • The PR description states all four affected test files pass on the debug build with the real 16-bit inputs, and the change is already on main as 816f8930.
  • No prior reviews or comments on the timeline.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Nothing to change from the review. One correction to it: 816f893 is this branch's head, not a commit on main; the PR is not merged yet. Buildkite build #100253 is still running (24 of 179 jobs done so far).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant