Skip to content

Implement tryReadSync/tryWriteSync support - #7036

Open
jasnell wants to merge 1 commit into
mainfrom
jasnell/try-read-write-sync
Open

Implement tryReadSync/tryWriteSync support#7036
jasnell wants to merge 1 commit into
mainfrom
jasnell/try-read-write-sync

Conversation

@jasnell

@jasnell jasnell commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

@jasnell
jasnell requested review from a team as code owners August 17, 2026 20:19
@ask-bonk

ask-bonk Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Model not found: cloudflare-ai-gateway/anthropic/claude-opus-4-6. Did you mean: anthropic/claude-opus-4.5, anthropic/claude-opus-4.6, anthropic/claude-opus-4.7?

github run

@ask-bonk

ask-bonk Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

@jasnell Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@codecov-commenter

codecov-commenter commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 56.69291% with 220 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.76%. Comparing base (482c2be) to head (30cf740).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
src/workerd/api/streams/internal.c++ 61.62% 52 Missing and 14 partials ⚠️
src/workerd/io/external-pusher.c++ 10.00% 26 Missing and 1 partial ⚠️
src/workerd/api/system-streams.c++ 53.70% 21 Missing and 4 partials ⚠️
src/workerd/util/stream-utils-test.c++ 63.23% 0 Missing and 25 partials ⚠️
src/workerd/api/streams/compression.c++ 54.54% 17 Missing and 3 partials ⚠️
src/workerd/server/container-client.c++ 0.00% 20 Missing ⚠️
src/workerd/api/util.c++ 40.00% 18 Missing ⚠️
src/workerd/api/streams/internal-test.c++ 57.69% 0 Missing and 11 partials ⚠️
src/workerd/util/stream-utils.c++ 85.71% 3 Missing and 2 partials ⚠️
src/workerd/api/streams/standard.c++ 72.72% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7036      +/-   ##
==========================================
- Coverage   67.80%   67.76%   -0.04%     
==========================================
  Files         468      471       +3     
  Lines      132283   132805     +522     
  Branches    21474    21578     +104     
==========================================
+ Hits        89691    89994     +303     
- Misses      29520    29676     +156     
- Partials    13072    13135      +63     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jasnell
jasnell force-pushed the jasnell/try-read-write-sync branch from ae6f334 to 30cf740 Compare August 17, 2026 20:40
@codspeed-hq

This comment was marked as low quality.

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

Findings from a full read-through (inline comments below):

  • [HIGH] Queued sync write path leaks write-buffer accounting (adjustWriteBufferSize/onChunkDequeued never balanced) — permanent backpressure with a highWaterMark, observer metric skew.
  • [HIGH] Pipe::write fast path doesn't handle a sync throw from tryWriteSync(), ending in a different terminal state than an async write rejection.
  • [MEDIUM] write() fast path forwards zero-length writes to the sink; the queued path deliberately never does.
  • [BLOCKING] capnp pin has an empty sha256 and points at the unmerged capnproto PR head (presumably the internal-build failure).
  • [QUESTION] DEFAULT_AUTO_ALLOCATE_CHUNK_SIZE_2 16K→32K bump bundled in, mid-autogate-rollout.
  • [QUESTION] The JS-observable timing changes ship ungated: reader.read()/writer.write() promises now settle without the event-loop round trip for compression streams, memory-backed bodies, and drainingRead. The identity-transform note in this PR documents exactly this ordering inversion breaking its own backpressure test — that stream was exempted, but the same class of user-observable reordering now applies everywhere else. Should the controller-level fast paths (the JS-visible ones, as opposed to the pure C++ pump paths) sit behind an autogate for rollout, matching how UPDATED_AUTO_ALLOCATE_CHUNK_SIZE is being rolled out?
  • [LOW] Redundant try/catch in pumpToImpl.

The wrapper forwarding (tee, neuterable, abortable, encoded, container-client) all respect the decline/no-side-effect contract, and the ResponseStreamWrapper lock-recursion note is important. Test coverage note: none of the new tests exercise the queued sync write path in writeLoopAfterFrontOutputLock, which is where the accounting bug lives.

This review was written with AI assistance and may contain mistakes; treat each finding on its merits.

Comment on lines +1944 to +1951
if (syncSuccess) {
maybeResolvePromise(js, request.promise);
queue.pop_front();

if (maybeAbort(js, *this)) return js.resolvedPromise();

return writeLoop(js, ioContext, syncDepth + 1);
}

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.

The enqueue side charged adjustWriteBufferSize(js, len) + onChunkEnqueued(len) in write(), and both async completion continuations balance with adjustWriteBufferSize(js, -amountToWrite) + onChunkDequeued(amountToWrite) before popping. This sync-success path pops without either, and drain()/doError() never reset currentWriteBufferSize, so every queued write completed synchronously leaks amountToWrite into the accounting. With a highWaterMark set that becomes permanent backpressure (writer.ready is replaced and never resolves; desiredSize skews negative), and the StreamObserver enqueue/dequeue metrics diverge.

Suggested change
if (syncSuccess) {
maybeResolvePromise(js, request.promise);
queue.pop_front();
if (maybeAbort(js, *this)) return js.resolvedPromise();
return writeLoop(js, ioContext, syncDepth + 1);
}
if (syncSuccess) {
maybeResolvePromise(js, request.promise);
adjustWriteBufferSize(js, -amountToWrite);
KJ_IF_SOME(o, observer) {
o->onChunkDequeued(amountToWrite);
}
queue.pop_front();
if (maybeAbort(js, *this)) return js.resolvedPromise();
return writeLoop(js, ioContext, syncDepth + 1);
}

The sync KJ_CATCH path above has the same omission relative to the async error continuation, which also does -amountToWrite + onChunkDequeued before rejecting.

Comment on lines +2356 to +2361
// Fast path: complete the write synchronously when the sink can accept data immediately.
auto syncResult = KJ_ASSERT_NONNULL(parent.state.whenActive(
[&](IoOwn<Writable>& writable) { return writable->sink->tryWriteSync(data); }));
if (syncResult) {
return js.resolvedPromise();
}

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.

This is the one tryWriteSync() call site that doesn't map a synchronous throw onto the async-rejection handling. A throw here propagates out of the read continuation in pipeLoop(), skipping the write-rejection functor (tryErrorParent → destination doError, sink abort on the next loop iteration) and instead landing in handlePromise's error functor — which errors the source and rejects the pipe promise but leaves the destination controller un-errored and the sink un-aborted. Different terminal state than an async write failure.

Surfacing the throw as a rejected promise routes it through the exact same continuation as an async rejection:

Suggested change
// Fast path: complete the write synchronously when the sink can accept data immediately.
auto syncResult = KJ_ASSERT_NONNULL(parent.state.whenActive(
[&](IoOwn<Writable>& writable) { return writable->sink->tryWriteSync(data); }));
if (syncResult) {
return js.resolvedPromise();
}
// Fast path: complete the write synchronously when the sink can accept data immediately.
bool syncSuccess = false;
KJ_TRY {
syncSuccess = KJ_ASSERT_NONNULL(parent.state.whenActive(
[&](IoOwn<Writable>& writable) { return writable->sink->tryWriteSync(data); }));
}
KJ_CATCH(exception) {
// A sync throw is equivalent to a rejected write() promise; surface it as one so the
// caller's rejection continuation handles it identically to an async write failure.
return js.rejectedPromise<void>(js.exceptionToJs(kj::mv(exception)));
}
if (syncSuccess) {
return js.resolvedPromise();
}

//
// TODO(perf): Consider adding a synchronous "output gate is open" check so that actors
// can also take this fast path when no storage writes are pending.
if (queue.empty() && maybePendingAbort == kj::none &&

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.

Zero-length writes reach the sink through this fast path: processChunk returns a non-none empty array for empty strings, so tryWriteSync(empty) gets invoked. The queued path deliberately never forwards zero-length writes to the sink (see the note in writeLoopAfterFrontOutputLock about distinguishing disconnections from zero-length reads on the other end of a TransformStream). Excluding them here preserves the queued no-op semantics:

Suggested change
if (queue.empty() && maybePendingAbort == kj::none &&
if (len > 0 && queue.empty() && maybePendingAbort == kj::none &&

name = "capnp-cpp",
sha256 = "6753378bd099029cb2830fecd32dd158218019e459ffd3c8e379cbf025906eb8",
strip_prefix = "capnproto-capnproto-a1cd1c4/c++",
sha256 = "",

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.

Blocking: empty sha256, and the tarball points at the head of the unmerged capnproto PR (capnproto/capnproto#2740). Needs repinning to the merged capnp commit with the real hash before this can land — presumably also the cause of the internal-build failure.

// so carefully to avoid introducing memory regressions and causing workers to
// hit OOM errors. We'll use an autogate to roll out the new default.
static constexpr int DEFAULT_AUTO_ALLOCATE_CHUNK_SIZE_2 = 16 * 1024;
static constexpr int DEFAULT_AUTO_ALLOCATE_CHUNK_SIZE_2 = 32 * 1024;

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.

This 16 KiB → 32 KiB bump looks unrelated to tryReadSync/tryWriteSync and isn't mentioned in the PR description. It's also the value behind the in-flight UPDATED_AUTO_ALLOCATE_CHUNK_SIZE autogate — if that gate is partially rolled out, this silently doubles the allocation mid-rollout. Intentional? If so it seems worth its own PR with the memory-regression reasoning the comment above alludes to.

Comment on lines +3575 to +3587
bool syncSuccess = false;
KJ_TRY {
syncSuccess = sink->tryWriteSync(pieces);
}
KJ_CATCH(exception) {
// tryWriteSync() may throw when a synchronous write is possible but fails. Per
// the tryWriteSync() contract this is equivalent to a rejected write() promise.
writeFailed = true;
kj::throwFatalException(kj::mv(exception));
}
if (!syncSuccess) {
co_await sink->write(pieces);
}

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.

The KJ_TRY/KJ_CATCH is redundant: KJ_ON_SCOPE_FAILURE(writeFailed = true) two lines up already covers a synchronous throw from tryWriteSync(), so a bare call behaves identically.

Suggested change
bool syncSuccess = false;
KJ_TRY {
syncSuccess = sink->tryWriteSync(pieces);
}
KJ_CATCH(exception) {
// tryWriteSync() may throw when a synchronous write is possible but fails. Per
// the tryWriteSync() contract this is equivalent to a rejected write() promise.
writeFailed = true;
kj::throwFatalException(kj::mv(exception));
}
if (!syncSuccess) {
co_await sink->write(pieces);
}
if (!sink->tryWriteSync(pieces)) {
co_await sink->write(pieces);
}

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.

3 participants