Skip to content

CompressionStream: register a chunk's promise before its first codec step - #38970

Closed
robobun wants to merge 1 commit into
mainfrom
farm/b7766784/codec-promise-before-first-step
Closed

CompressionStream: register a chunk's promise before its first codec step#38970
robobun wants to merge 1 commit into
mainfrom
farm/b7766784/codec-promise-before-first-step

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #38695, closing the last variant of the abort hang found in its review.

Problem

  • writer.abort() (or reader.cancel()) issued from inside a chunk's first codec step leaves write(), abort() and writer.closed pending forever. Reaching that point takes an Object.prototype.then getter: enqueueing the first piece resolves the pending read(), and resolving a promise with an object looks up its then, so that getter runs synchronously inside the step loop. Not reachable from ordinary code, so this is about closing the class rather than a user report.
  • Cause: transformChunk (src/jsc/bindings/webcore/streams/JSCompressionStreamShared.cpp) only created m_codecPromise after stepChunkHere returned Pending. An abandon issued during the first steps (writableStreamStartErroring, the source cancel algorithm) found nothing to settle, stepChunkHere only checked for that on continuations, and the chunk was then parked on a writable already in erroring, which nothing settles unless the user happens to keep reading.

Fix

  • transformChunk creates and stores the promise before the first step, for the on-thread path as the thread-pool path already did, and applies the outcome with the existing settlePendingChunk. The first call now takes exactly the continuation path: an abandon from inside a delivery resolves the promise and stepChunkHere stops, as it already did for later steps; otherwise Done / Failed / DoneSinkFull settle the same promise that used to be created per case, so the separate switch goes away (9 lines added, 22 removed in the file).
  • Settling semantics are unchanged: promiseFulfilledWith / promiseRejectedWith were create-then-fulfill / create-then-reject of a fresh promise, which is what settleCodecChunk does to the pre-created one; the sink-full case moves the same promise into m_nativeSinkReadyPromise as before.
  • Verified: new test writer.abort() re-entered from the chunk's first step settles the write in test/js/web/streams/compression.test.ts times out on main (the write() never settles; a probe confirms abort() was called and the first 64 KiB piece was delivered) and passes with the change; the whole file (66 tests), the vendored WPT streams suite, streams.test.js, the TextEncoderStream/TextDecoderStream tests and the node webstreams compression tests pass under BUN_JSC_validateExceptionChecks=1; a re-entrant reader.cancel() variant for brotli/gzip/zstd settles cleanly under ASAN.

Background

  • Since CompressionStream/DecompressionStream: emit each chunk's output in bounded steps #38695 a native CompressionStream/DecompressionStream emits each input chunk in steps of at most highWaterMark bytes. When the consumer is full, the chunk's transform promise (m_codecPromise) stays pending, which keeps the write in flight; the consumer's pull (or the native sink's onReady) runs further steps, and whichever side goes away first (writable erroring, readable cancel, sink detach) calls nativeCodecAbandon, which resolves that promise so the writable can finish erroring. That protocol assumed the promise exists whenever an abandon can happen; this change makes that true for the first step as well.

…step

Delivering a step can run user code: enqueueing the first piece resolves a
pending read(), and resolving a promise with an object looks up its then.
An abort or cancel issued from there found no promise to abandon, and the
chunk was then parked in a state nothing would settle, so write() and
abort() hung. Creating the promise up front makes the first call take the
same path as a continuation and removes the separate settle switch.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9e0767e0-f682-469b-a005-686622f40571

📥 Commits

Reviewing files that changed from the base of the PR and between 8437683 and 983b221.

📒 Files selected for processing (2)
  • src/jsc/bindings/webcore/streams/JSCompressionStreamShared.cpp
  • test/js/web/streams/compression.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: closed without merging. The only way to reach the hang this fixed is an Object.prototype.then getter, and the maintainer decided that case does not need handling; the merged #38695 stands as is.

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

I reviewed this PR and didn't find any bugs. Because it reworks re-entrant promise/state lifecycle in the native streams layer, a maintainer familiar with #38695's stepping protocol should still give it a look.

What was reviewed:

  • Traced Done/Failed/DoneSinkFull/Pending through settlePendingChunk — each settles the pre-created promise the same way the old per-case switch did.
  • Checked that takeCodecPromisenativeTransformReleaseStateIfIdle inside transformChunk is safe: runNativeArm holds m_nativeStateInUse, so the release defers to its epilogue.
  • Confirmed the abandoned-during-first-step path: nativeCodecAbandon resolves and clears m_codecPromise, stepChunkHere returns Pending, and transformChunk returns the already-resolved promise.
  • Test restores Object.prototype.then in both the getter and a finally, and asserts the getter fired.
Extended reasoning...

Overview

This PR touches one C++ file (src/jsc/bindings/webcore/streams/JSCompressionStreamShared.cpp, +9/−22) and adds one test to test/js/web/streams/compression.test.ts. It is a follow-up to #38695: transformChunk now creates and stores m_codecPromise before the first codec step (previously only after stepChunkHere returned Pending), so a re-entrant nativeCodecAbandon reached from inside the first step's delivery finds a promise to settle. The per-case switch that used to create a fresh promise per outcome is replaced by the existing settlePendingChunk, unifying the first-call path with the continuation path. stepChunkHere drops its continuation flag since m_codecPromise is now always set at entry.

Security risks

None identified. The scenario requires a poisoned Object.prototype.then getter to reach; it's a hang, not a memory-safety issue. No untrusted input parsing, allocation sizing, or privilege boundary is involved.

Level of scrutiny

High. This is native JSC bindings code that manipulates a WriteBarrier<JSPromise> and interacts with re-entrant user JS during promise resolution — exactly the memory-safety / "user JS can synchronously free your state" territory the review guidelines flag as most-blocked. I traced each outcome case for behavioral equivalence and verified the new takeCodecPromise call inside transformChunk cannot free the coder early (nativeTransformReleaseStateIfIdle at JSTransformStreamDefaultController.cpp:406 short-circuits on m_nativeStateInUse, which runNativeArm holds around the arm). The RETURN_IF_EXCEPTION(scope, nullptr) early-return now leaves m_codecPromise set where it previously did not, but that path is VM termination only (runStepHere catches ordinary throws into step.thrown via a top exception scope).

Other factors

The change is a net simplification that makes the on-thread first call use the same settle path nativeCodecContinue and deliverAsync already use. The PR description reports the whole file, the WPT streams suite, and related tests passing under BUN_JSC_validateExceptionChecks=1, plus an ASAN check of the reader.cancel() variant. The new test properly restores the mutated Object.prototype in a finally and asserts the getter actually fired. Still, given the subtlety of re-entrant abandon during a step loop and this being native streams lifecycle code, deferring to a human familiar with the #38695 design is the safer call.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

We can skip handling users overriding Object.prototype.then here.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Understood, closing; the merged #38695 stands as is.

@robobun
robobun deleted the farm/b7766784/codec-promise-before-first-step branch August 15, 2026 10:01
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