Skip to content

Conversation

@j-tyler
Copy link
Contributor

@j-tyler j-tyler commented Nov 11, 2025

Summary

ByteBufferAsyncWritableChannel.write(ByteBuffer) creates a memory leak by wrapping the input ByteBuffer in a Netty ByteBuf wrapper without releasing it. Callback has no reference to it. According to Netty's reference counting contract, whoever creates or retains a ByteBuf is responsible for releasing it. Since ByteBufferAsyncWritableChannel creates the wrapper internally, it must release it. The original code never calls wrapper.release(), causing every call to write(ByteBuffer) to leak native memory.

The fix adds a boolean flag to ChunkData to mark which ByteBufs are internal wrappers that need releasing then using that knowledge to correctly release the ByteBuf when needed.

Testing Done

ByteBufferAsyncWritableChannelTest.testWriteByteBufferReleasesWrapper proves the bug by:

  1. Calling write(ByteBuffer) which creates an internal wrapper
  2. Using reflection to extract the wrapper ByteBuf from the channel's internal queue
  3. Verifying the wrapper has refCnt=1 before resolution
  4. Calling resolveOldestChunk() to complete the normal flow
  5. Asserting the wrapper has refCnt=0 (released) after resolution

@codecov-commenter
Copy link

codecov-commenter commented Nov 11, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 69.91%. Comparing base (52ba813) to head (46a3231).
⚠️ Report is 324 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3171      +/-   ##
============================================
+ Coverage     64.24%   69.91%   +5.67%     
- Complexity    10398    12799    +2401     
============================================
  Files           840      930      +90     
  Lines         71755    78941    +7186     
  Branches       8611     9428     +817     
============================================
+ Hits          46099    55193    +9094     
+ Misses        23004    20834    -2170     
- Partials       2652     2914     +262     

☔ View full report in Codecov by Sentry.
📢 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.

ByteBufferAsyncWritableChannel.write(ByteBuffer) creates a memory leak by wrapping the input
ByteBuffer in a Netty ByteBuf wrapper without releasing it. Callback has no reference to it.
According to Netty's reference counting contract, whoever creates or retains a ByteBuf is
responsible for releasing it. Since ByteBufferAsyncWritableChannel creates the wrapper internally,
it must release it. The original code never calls wrapper.release(), causing every call to
write(ByteBuffer) to leak native memory.

ByteBufferAsyncWritableChannelTest.testWriteByteBufferReleasesWrapper proves the bug by:

1. Calling write(ByteBuffer) which creates an internal wrapper
2. Using reflection to extract the wrapper ByteBuf from the channel's internal queue
3. Verifying the wrapper has refCnt=1 before resolution
4. Calling resolveOldestChunk() to complete the normal flow
5. Asserting the wrapper has refCnt=0 (released) after resolution

The fix adds a boolean flag to ChunkData to mark which ByteBufs are internal wrappers that
need releasing then using that knowledge to correctly release the ByteBuf when needed.
@j-tyler j-tyler force-pushed the j-tyler/fix-bytebuffer-async-writable-channel branch from 4786b0c to fa38498 Compare November 11, 2025 01:37
chunkData.resolveChunk(e);
// Release wrapper if it was created by write(ByteBuffer)
if (chunkData.isInternalWrapper && chunkData.buf != null) {
chunkData.buf.release();
Copy link
Contributor

Choose a reason for hiding this comment

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

would it make sense to make this part of chunkData.resolveChunk() instaed of having this logic in multiple places?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, this cleans it up nicely. Done.

throw new IllegalArgumentException("Source buffer cannot be null");
}
return write(Unpooled.wrappedBuffer(src), callback);
if (!isOpen()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

i think we also need src.release() here.

@SophieGuo410 made similar change https://github.com/linkedin/ambry/pull/3162/files please check if that is sufficient.

Copy link
Contributor

Choose a reason for hiding this comment

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

the reason we remove the isOpen logic in write is because we think if isOpen is false, it should already call the resolveAllRemainingChunks in close() to release the buf. Do you think if that's good enough?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, two issues in the same class! I pulled both changes together and deduplicated the logic in write().

callback.onCompletion(bytesWritten, exception);
}
// Release wrapper if it was created by write(ByteBuffer)
if (isInternalWrapper) {
Copy link
Contributor

@SophieGuo410 SophieGuo410 Nov 14, 2025

Choose a reason for hiding this comment

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

based on my understanding, the buf should be released through callback?
Referring something like
ContentWriteCallback.onCompletion

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The callback doesn't have reference to the ByteBuf if that ByteBuf was created internally on write with a ByteBuffer.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see, I thought it will release itself, but turns out it's not due to tryFree is false?

@SophieGuo410 SophieGuo410 merged commit 64f303f into linkedin:master Nov 17, 2025
5 checks passed
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.

4 participants