Skip to content

TextEncoderStream native-sink: write straight to the sink's write_latin1/write_utf16 - #36877

Open
robobun wants to merge 2 commits into
mainfrom
farm/4aff655e/textencoderstream-ascii-zerocopy
Open

TextEncoderStream native-sink: write straight to the sink's write_latin1/write_utf16#36877
robobun wants to merge 2 commits into
mainfrom
farm/4aff655e/textencoderstream-ascii-zerocopy

Conversation

@robobun

@robobun robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #36695.

What

When a native JSSink is attached (m_nativeSinkPtr), TextEncoderStreamEncoder__encodeIntoSink now hands the chunk's WTFStringImpl bytes straight to the sink via SinkHandle::write_latin1 / SinkHandle::write_utf16 instead of first transcoding into a per-encoder scratch Vec<u8> and then calling write_bytes.

Why

Every sink's write_latin1 already has an is_all_ascii fast path that forwards the input slice verbatim (and HTTPServerWritable sends it straight to the socket when the buffer is empty and the chunk exceeds the high-water mark), so an all-ASCII 8-bit chunk is now zero-copy from the JSString to the sink. Non-ASCII Latin-1 and UTF-16 chunks are transcoded once, directly into the sink's own buffer, instead of once into the scratch Vec and then memcpy'd into the sink. This was the open follow-up noted in #36695 ("TextEncoderStream pure-ASCII zero-copy path (GCOwned<StringView>Temporary straight to the sink)").

How

  • SinkHandle grows write_latin1 / write_utf16 that dispatch to each sink's existing JsSinkType implementation. RewriterPipe gains &self inherent write_latin1 / write_utf16 (the JsSinkType impl now delegates) so the shared-BackRef HTMLRewriter arm can call them.
  • 8-bit chunk with no carried surrogate → handle.write_latin1(string_bytes) directly.
  • 16-bit chunk with no carried surrogate → trim a trailing lone lead into pending_lead_surrogate, then handle.write_utf16(remainder) directly. Every sink's write_utf16 (simdutf fast path, decode_utf16_with_fffd fallback) emits U+FFFD for mid-chunk lone surrogates exactly as the encoder did; the trailing-lead position is the one place the encoder differs, and that is handled by the trim.
  • A chunk that follows a carried lead surrogate still goes through the scratch buffer so the replacement/astral prefix and the body produce one Writable (one backpressure signal).
  • The coerced JSString is now rooted across the sink write (to_js_string + EnsureStillAlive, the same pattern as JSSink::js_write). This matters because HTMLRewriter's write runs user JS content handlers while the input slice is still being parsed, and ArrayBufferSink's source.ready is also a GC point; when chunk was not already a string, toStringOrNull creates a fresh JSString that get_zig_string did not root.

Tests

  • text-encoder-stream.test.ts: native-sink round-trips for 8-bit (ASCII + Latin-1 non-ASCII) and 16-bit (mid-chunk and cross-chunk surrogate) chunks against Bun.serve; an HTMLRewriter sink with ToString-coerced chunks and Bun.gc(true) inside the element handler; native-sink backpressure to a stalled client; readable-side backpressure (HWM=1, second write parks until drained).
  • text-decoder-stream.test.ts: readable-side backpressure for both the utf-8 fast path and the fatal:true decoder path.

Benchmark

Release build, ReadableStream<string> (256 KiB all-ASCII, 2000 chunks) → TextEncoderStreamBun.servefetch().arrayBuffer(), 5-run median on a shared-tenant container:

main 59cab0e97 this PR
throughput 634 MB/s 692 MB/s

The HTTP stack still dominates; the win is one scratch allocation + one memcpy eliminated per chunk on the native-sink path.

…/write_utf16

When a native JSSink is attached, TextEncoderStreamEncoder__encodeIntoSink now
passes the chunk's WTFStringImpl bytes straight to the sink via the sink's own
write_latin1/write_utf16 instead of first transcoding into a per-encoder
scratch Vec<u8> and then calling write_bytes. Every sink's write_latin1 already
has an is_all_ascii fast path that forwards the input slice verbatim, so an
all-ASCII 8-bit chunk is now zero-copy from the JSString all the way to the
sink buffer (or the socket, for HTTPServerWritable with no buffered data).

For 16-bit chunks the encoder trims a trailing lone lead surrogate into
pending_lead_surrogate and hands the remainder to write_utf16; every sink's
write_utf16 emits U+FFFD for mid-chunk lone surrogates exactly as the encoder
did. Only a chunk that follows a carried lead surrogate still goes through the
scratch buffer, so the prefix and body produce one Writable.

SinkHandle grows write_latin1/write_utf16 that dispatch to each sink's
implementation (RewriterPipe gains &self inherent methods so the shared-BackRef
HTMLRewriter arm can reach them). The coerced JSString is now rooted across the
sink write (to_js_string + EnsureStillAlive, as in JSSink::js_write), since
HTMLRewriter content handlers and ArrayBufferSink's onReady are GC points.

Tests: native-sink coverage for 8-bit (ASCII + Latin-1 non-ASCII) and 16-bit
(mid-chunk + cross-chunk surrogate) chunks, an HTMLRewriter sink with
ToString-coerced chunks and Bun.gc(true) inside the handler, and readable-side
+ native-sink backpressure tests for TextEncoderStream and readable-side
backpressure tests for TextDecoderStream.
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Verified locally: bun bd test test/js/web/encoding/text-encoder-stream.test.ts test/js/web/encoding/text-decoder-stream.test.ts test/js/web/streams/compression.test.ts test/js/web/encoding/encode-bad-chunks.test.ts → 121 pass, 0 fail.

@github-actions github-actions Bot added the claude label Aug 4, 2026
@coderabbitai

coderabbitai Bot commented Aug 4, 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: b9c0b07a-0c5b-4ab5-9640-f4dde772d949

📥 Commits

Reviewing files that changed from the base of the PR and between 7762b03 and 7447496.

📒 Files selected for processing (3)
  • src/runtime/webcore/TextEncoderStreamEncoder.rs
  • test/js/web/encoding/text-decoder-stream.test.ts
  • test/js/web/encoding/text-encoder-stream.test.ts

Walkthrough

Changes

Native sinks now support direct Latin-1 and UTF-16 writes. TextEncoderStreamEncoder handles surrogate boundaries and preserves JSString values. Tests cover encoding correctness, HTMLRewriter integration, and stream backpressure.

Native encoding sink writes

Layer / File(s) Summary
Encoding-specific sink dispatch
src/runtime/api/html_rewriter.rs, src/runtime/webcore.rs
RewriterPipe converts encoded input when required. SinkHandle dispatches Latin-1 and UTF-16 writes to supported sinks.
Native encoder dispatch
src/runtime/webcore/TextEncoderStreamEncoder.rs
The encoder sends Latin-1 and UTF-16 chunks directly to sink methods. Trailing lead surrogates remain pending across chunks.
Encoding and backpressure validation
test/js/web/encoding/*
Tests validate encoded output, surrogate handling, string rooting, native sink backpressure, and decoder backpressure.

Possibly related PRs

  • oven-sh/bun#33825: Modifies incremental stream sink encoding paths for Latin-1, UTF-16, and UTF-8 data.
  • oven-sh/bun#36695: Introduces native text-stream sink infrastructure extended by this PR.
  • oven-sh/bun#36733: Adds streaming sink infrastructure extended with Latin-1 and UTF-16 paths.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: direct native-sink writes through write_latin1 and write_utf16.
Description check ✅ Passed The description explains the change, rationale, implementation, tests, and benchmark results, satisfying the repository template requirements.
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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/encoding/text-encoder-stream.test.ts`:
- Around line 361-375: Update waitUntilStable to use a named maximum
event-loop-turn limit instead of an unbounded loop with arbitrary Bun.sleep
timing, and return whether stability was reached before the limit. At the call
sites around the stalled-client checks, assert the helper reports success before
recording pullsWhileStalled, while preserving the existing total threshold
behavior.
- Around line 336-358: Extend the 8-bit coercion case in the test
"TextEncoderStream -> HTMLRewriter native sink: non-string chunks + handler that
allocates" with a non-ASCII Latin-1 character, and update the expected output
assertion to include that character unchanged. Keep the existing ASCII and
UTF-16 coercion cases and element-count assertion intact.
- Around line 425-428: Update the tests at
test/js/web/encoding/text-encoder-stream.test.ts:425-428 and
test/js/web/encoding/text-decoder-stream.test.ts:285-288 to store each
writer.close() promise, drain the reader as currently done, then await the
stored promise so close rejections fail the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c77d68f8-9315-4a61-8041-452dfbcc658b

📥 Commits

Reviewing files that changed from the base of the PR and between 2190ef1 and 7762b03.

📒 Files selected for processing (5)
  • src/runtime/api/html_rewriter.rs
  • src/runtime/webcore.rs
  • src/runtime/webcore/TextEncoderStreamEncoder.rs
  • test/js/web/encoding/text-decoder-stream.test.ts
  • test/js/web/encoding/text-encoder-stream.test.ts

Comment thread test/js/web/encoding/text-encoder-stream.test.ts Outdated
Comment thread test/js/web/encoding/text-encoder-stream.test.ts Outdated
Comment thread test/js/web/encoding/text-encoder-stream.test.ts Outdated

@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.

Beyond the inline nit, I also checked: the trailing-lone-lead trim in the new 16-bit fast path is correct for the [lead], [lead, lead], and [..., lead, trail] edge cases (u16_is_lead is ICU U16_IS_LEAD, so a last-position lead is always unpaired); and the new SinkHandle::write_latin1/write_utf16 match SinkHandle::write arm-for-arm. Verifiers also examined whether the new 16-bit surrogate test's ref compares encodeIntoSink against itself and concluded it does not — and the WPT testCases block anchors the surrogate semantics against hardcoded bytes via the non-sink encodeForStream path regardless.

Extended reasoning...

This is a ~200-line native hot-path change that restructures an extern "C" entry point, adds new unsafe dispatch arms on SinkHandle, changes the GC-rooting strategy (get_zig_stringto_js_string + EnsureStillAlive), and relies on a cross-sink semantic-equivalence claim (every sink's write_utf16 replaces lone surrogates identically to the encoder). It looks correct and is well-tested, but it is not a mechanical change — deferring to human review.

Comment thread src/runtime/webcore/TextEncoderStreamEncoder.rs Outdated
Comment thread test/js/web/encoding/text-decoder-stream.test.ts Outdated
Comment thread test/js/web/encoding/text-encoder-stream.test.ts Outdated
Comment thread test/js/web/encoding/text-encoder-stream.test.ts Outdated
Comment thread test/js/web/encoding/text-encoder-stream.test.ts Outdated
Comment thread test/js/web/encoding/text-encoder-stream.test.ts Outdated
Comment thread test/js/web/encoding/text-encoder-stream.test.ts Outdated
…ressure, fix SAFETY comment

- Parameterize the native-sink round-trip tests (8-bit, 16-bit, HTMLRewriter)
  over two source shapes: start() enqueues everything up front, and async pull
  with a macrotask yield so each chunk goes through encodeIntoSink while the
  response is streaming.
- Replace the time-polled native-sink backpressure test with a writer-driven
  one: write 256 KiB chunks until a write stays pending across a macrotask
  (Bun.peek.status), then drain the client and assert the parked write
  resolves and the byte count matches. The previous version also had a
  writer<->fetch deadlock because fetch() waits for the first body bytes.
- Readable-side backpressure tests (encoder + decoder) now use
  Bun.peek.status(second) instead of racing against Bun.sleep(0).
- Await writer.close() after draining so a close rejection fails the test.
- Add a non-ASCII Latin-1 chunk to the HTMLRewriter rooting test so the
  RewriterPipe write_latin1 Vec-copy branch is also covered.
- Reword the SAFETY comment on the encoder &*this borrow: the sink write can
  run user JS; the soundness argument is shared-borrow + Cell/RefCell, not
  absence of user JS.
Comment thread src/runtime/webcore/TextEncoderStreamEncoder.rs
Comment thread src/runtime/webcore/TextEncoderStreamEncoder.rs
Comment thread src/runtime/webcore/TextEncoderStreamEncoder.rs
Comment thread src/runtime/webcore/TextEncoderStreamEncoder.rs
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Chain benchmarks

Release build, 5-run median, 200 x 64 KiB chunks x 30 iterations per run, pull-driven source -> fetch().arrayBuffer(). Shared-tenant container, so run-to-run variance is ~5-8%.

chain (sink the encoder writes to) main 59cab0e97 this PR delta
TES -> serve (ascii) 803 MB/s 798-827 MB/s ~noise
TES -> serve (latin1 non-ascii) 734 MB/s 736-794 MB/s ~noise / slight up
TES -> serve (utf16) 634 MB/s 684-708 MB/s +8%
TES -> HTMLRewriter -> serve (ascii html) 17.3 MB/s 15.7 MB/s -9% (lol_html-bound; is_all_ascii scan on string bytes instead of memcpy into scratch)
TES -> CompressionStream(gzip) -> serve (ascii) 2075 MB/s 2155 MB/s ~noise (gzip-bound, tiny output)
TES -> HTMLRewriter -> gzip -> serve hangs on main same pre-existing; filed as a separate bug

The HTTP/rewriter/gzip stages dominate these numbers; the actual change is one scratch allocation + one memcpy eliminated per chunk (or two memcpys for the ascii case when HTTPServerWritable sends direct-to-socket), which is below the noise floor of most of these except the utf16 path. The structural win is that 8-bit all-ASCII now takes the same path a direct-sink controller.write(string) already took.

The TES -> HTMLRewriter -> gzip -> serve hang reproduces on main with src/ reverted (it does not reproduce without TextEncoderStream in the chain, and 1.3.14 fails earlier with ERR_STREAM_CANNOT_PIPE). A separate session has that now.

@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Note on fail-before: this change moves which sink entrypoint the encoder calls (write_bytes on a scratch-converted UTF-8 buffer vs write_latin1/write_utf16 on the string's own bytes); the output bytes and the backpressure signal are identical by construction, so the new tests pass with and without the src/ change. The describe.each native-sink tests and the Bun.peek.status backpressure tests are coverage for the code paths this change now drives, not a proof of a pre-existing bug.

@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.

No issues found on the updated commit; deferring so Jarred can confirm the reworked tests and the four open comment-cop notes on TextEncoderStreamEncoder.rs.

What was reviewed:

  • Trailing-lead-surrogate trim before write_utf16: verified it matches encode_utf16_into's carry semantics for [lead], […, lead, lead], […, lead, trail], and [trail]; the 16-bit sink test diffs against the non-sink reference path.
  • to_js_string + EnsureStillAlive rooting across sink writes that re-enter user JS (HTMLRewriter handler + Bun.gc(true) test covers the ToString-coerced case).
  • SinkHandle::write_latin1/write_utf16 arms mirror SinkHandle::write exactly (same backref/SAFETY discipline; ServerResponse unreachable via sink_handle_from_id).
  • Reworked backpressure tests: Bun.peek.status on the parked write, bounded loop, writer.close() awaited — no time-based polling remains.
Extended reasoning...

Overview

Follow-up perf change to #36695: TextEncoderStreamEncoder__encodeIntoSink now hands the coerced JSString's bytes directly to the sink via new SinkHandle::write_latin1/write_utf16 methods (which fan out to each sink's existing JsSinkType impl) instead of transcoding into a scratch Vec<u8> first. The scratch path is retained only for the carried-lead-surrogate case. RewriterPipe grows &self inherent write_latin1/write_utf16 so the shared-BackRef arm can call them. The coerced string is now explicitly rooted (to_js_string + EnsureStillAlive) across the sink write. Tests add describe.each over start/async-pull sources, an HTMLRewriter GC-stress case, and Bun.peek.status-based backpressure assertions.

Security risks

None user-facing. The memory-safety surface is the interesting part: (1) the WTFStringImpl slice is now borrowed across a sink write that can run user JS (HTMLRewriter content handlers, ArrayBufferSink onReady) — the new EnsureStillAlive guard is what makes this sound, and the HTMLRewriter test with Bun.gc(true) inside the handler exercises it; (2) the unsafe { &*this } shared borrow is held across that same re-entry — the SAFETY comment was reworded per my earlier note to state the actual invariant (Cell/RefCell-only struct, owning cell on the C++ caller's stack).

Level of scrutiny

High. This is hot-path native code with three of REVIEW.md's most-blocked categories in play: GC rooting across user-JS re-entry, unsafe pointer deref with a hand-written SAFETY argument, and encoding correctness (surrogate carry now split between the encoder and each sink's write_utf16). The maintainer already engaged with review and requested specific test changes; those were applied in 7447496 but not yet re-reviewed.

Other factors

  • Four comment-cop bot comments on the new multi-line inline comments (lines ~278/291/327/346) are still open. The comments in question document GC-rooting rationale and surrogate-carry semantics rather than justifying workarounds, but per CLAUDE.md rule 13 a maintainer should decide whether to trim them.
  • I checked that the trailing-lead trim is equivalent to what to_utf8_list_with_type_bun::<true> produces on the slow path for the edge shapes the test enumerates, and that SinkHandle::write_latin1/write_utf16 mirror the existing write dispatch arm-for-arm.
  • Given Jarred was mid-review and the PR restructures a native fast path, auto-approval isn't appropriate here.

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.

2 participants