Fix: get_data_as_view retaion the backing reader/builder even after the view is released, leading to memory leakage (#406)

* fix: return empty memoryview for uninitialized DATA fields

Use a module-level sentinel when Cap'n Proto reports a NULL pointer with
zero size so PyBuffer_FillInfo receives a valid address for unset fields.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: release buffer info if memoryview construction fails

PyBuffer_FillInfo pins `self` via buf.obj; call PyBuffer_Release on failure
so that reference is not leaked. This is safe for sentinel-backed empty views:
PyBuffer_Release only decrements buf.obj and does not free buf.buf.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: clarify lifetime rules for zero-copy buffer views

Document borrowing semantics, mutation hazards, and empty DATA field
behavior for get_data_as_view and to_segment_views.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: pin DATA field views via shared buffer exporter

Replace PyMemoryView_FromBuffer with a _BorrowedBufferView holder and
PyMemoryView_FromObject so get_data_as_view() correctly pins the struct
reader/builder for the memoryview lifetime. Generalize the same exporter
for to_segment_views() and add regression tests for packed payload release.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: bigtailfox <leoherz.liu@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
leoherz.liu
2026-07-11 12:06:36 +08:00
committed by GitHub
parent 40189f91fc
commit 8b56bb8140
2 changed files with 201 additions and 55 deletions

View File

@@ -1,4 +1,8 @@
import os
import tempfile
import weakref
from pathlib import Path
import pytest
import capnp
import sys
@@ -152,6 +156,33 @@ def test_corner_cases_values(all_types):
assert msg.get_data_as_view("dataField").tobytes() == binary_data
def test_uninitialized_data_get_view(all_types):
"""
Default DATA fields should expose an empty memoryview instead of failing on a NULL buffer pointer.
"""
builder = all_types.TestAllTypes.new_message()
builder_view = builder.get_data_as_view("dataField")
assert isinstance(builder_view, memoryview)
assert builder_view.readonly is False
assert len(builder_view) == 0
assert builder_view.tobytes() == b""
reader = all_types.TestAllTypes.new_message().as_reader()
reader_view = reader.get_data_as_view("dataField")
assert isinstance(reader_view, memoryview)
assert reader_view.readonly is True
assert len(reader_view) == 0
assert reader_view.tobytes() == b""
with pytest.raises(IndexError):
builder_view[0] = 0xFF
with pytest.raises(ValueError):
builder_view[0:1] = b"\xff"
def test_error_wrong_type(all_types):
"""
Test error handling: Calling get_data_as_view on non-Data fields.
@@ -206,3 +237,56 @@ def test_view_keeps_message_alive(all_types):
gc.collect()
assert view.tobytes() == expected_data
def test_data_view_exports_through_buffer_exporter(all_types):
"""Returned memoryviews should pin an internal exporter, not bare pointers."""
msg = all_types.TestAllTypes.new_message()
msg.dataField = b"exporter_check"
view = msg.get_data_as_view("dataField")
assert isinstance(view, memoryview)
assert view.obj is not None
assert len(view.obj) == len(view)
def test_data_view_survives_del_builder(all_types):
msg = all_types.TestAllTypes.new_message()
msg.dataField = b"persistence_check"
view = msg.get_data_as_view("dataField")
del msg
gc.collect()
assert view.tobytes() == b"persistence_check"
def test_data_view_releases_packed_payload():
schema_text = """
@0x9d7d4f087df9b6e1;
struct BlobMsg {
data @0 :Data;
}
"""
class Payload(bytearray):
pass
td = tempfile.TemporaryDirectory()
path = Path(td.name) / "blob.capnp"
path.write_text(schema_text)
schema = capnp.load(str(path))
try:
payload = Payload(schema.BlobMsg.new_message(data=b"x" * 4096).to_bytes_packed())
payload_ref = weakref.ref(payload)
reader = schema.BlobMsg.from_bytes_packed(payload)
view = reader.get_data_as_view("data")
view.release()
del view, reader, payload
gc.collect()
assert payload_ref() is None
finally:
td.cleanup()