Files
pycapnp/test/test_async_write_large_payload.py
Jacob Alexander 162fddbcf6 Replace black and flake8 with ruff for linting and formatting
- Remove .flake8; add [tool.ruff] and [tool.ruff.format] config in pyproject.toml
  (line-length 120, excludes, ignore list, per-file-ignores, mccabe complexity)
- Update GitHub workflow lint job to run `ruff check .` and `ruff format --check .`
- Swap black and flake8 for ruff in requirements.txt and Pipfile
- Change capnp/__init__.py to ruff-style noqa comment
- Move max-complexity into [tool.ruff.lint.mccabe], lint options into [tool.ruff.lint]
- Add per-file-ignores for capnp/__init__.py (F401, F403, F405), remove inline noqa
- Run ruff format across codebase (24 files) for consistent style
2026-05-30 11:59:52 -07:00

109 lines
4.0 KiB
Python

"""
Regression test for use-after-free bug in async write with large payloads.
This test reproduces a bug where pipelining multiple RPC calls with large data
payloads (>~4000 bytes) causes memory corruption. The root cause was that
_PyAsyncIoStreamProtocol.write_loop() passed a memoryview pointing to C++
memory to transport.write(), then called fulfill() which freed the C++ memory.
Since transport.write() is non-blocking and buffers data asynchronously,
the data could be corrupted before being sent.
The fix is to copy the data to Python bytes before passing to transport.write().
See: https://github.com/capnproto/pycapnp/pull/392
"""
import pytest
import socket
import capnp
import test_capability_capnp
@pytest.fixture(autouse=True)
async def kj_loop():
async with capnp.kj_loop():
yield
class LargeResponseServer(test_capability_capnp.TestInterface.Server):
"""
Server that returns large text responses to trigger the use-after-free bug.
The bug manifests when response messages are >~4000 bytes.
"""
async def foo(self, i, j, **kwargs):
# Generate a large response string based on input
# The size is controlled by the input parameter 'i'
size = i
# Create a deterministic pattern that can be verified
pattern = "".join(chr(65 + (k % 26)) for k in range(size))
return pattern
async def test_large_response_sequential():
"""
Test that large RPC responses are received correctly when sent sequentially.
Tests various payload sizes including those >4000 bytes which trigger the bug.
"""
read_sock, write_sock = socket.socketpair()
read_stream = await capnp.AsyncIoStream.create_connection(sock=read_sock)
write_stream = await capnp.AsyncIoStream.create_connection(sock=write_sock)
_ = capnp.TwoPartyServer(write_stream, bootstrap=LargeResponseServer())
client = capnp.TwoPartyClient(read_stream)
cap = client.bootstrap().cast_as(test_capability_capnp.TestInterface)
# Test various sizes, including sizes that trigger the bug (>~4000 bytes)
test_sizes = [100, 1000, 4000, 5000, 8000]
for size in test_sizes:
response = await cap.foo(i=size, j=False)
# Verify the response has the correct length
assert len(response.x) == size, f"Size mismatch for {size}: expected {size}, got {len(response.x)}"
# Verify the pattern is correct (not corrupted)
expected = "".join(chr(65 + (k % 26)) for k in range(size))
assert response.x == expected, (
f"Data corruption detected for {size} bytes payload! "
f"First 50 chars: expected '{expected[:50]}', got '{response.x[:50]}'"
)
async def test_large_response_pipelined():
"""
Test that pipelining multiple RPC calls with large responses works correctly.
This is a more aggressive test that sends multiple requests without awaiting,
then collects all results. This pattern is more likely to trigger the
use-after-free bug because multiple messages are queued in the write buffer.
"""
read_sock, write_sock = socket.socketpair()
read_stream = await capnp.AsyncIoStream.create_connection(sock=read_sock)
write_stream = await capnp.AsyncIoStream.create_connection(sock=write_sock)
_ = capnp.TwoPartyServer(write_stream, bootstrap=LargeResponseServer())
client = capnp.TwoPartyClient(read_stream)
cap = client.bootstrap().cast_as(test_capability_capnp.TestInterface)
# Test sizes that trigger the bug - send 3 pipelined requests
test_sizes = [5000, 6000, 8000]
# Send all requests without awaiting (pipelining)
promises = []
for size in test_sizes:
promise = cap.foo(i=size, j=False)
promises.append((size, promise))
# Now await all responses and verify
for size, promise in promises:
response = await promise
assert len(response.x) == size, f"Size mismatch for {size}"
expected = "".join(chr(65 + (k % 26)) for k in range(size))
assert response.x == expected, f"Data corruption detected for {size} bytes payload!"