* 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>
293 lines
7.9 KiB
Python
293 lines
7.9 KiB
Python
import os
|
|
import tempfile
|
|
import weakref
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import capnp
|
|
import sys
|
|
import gc
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def all_types():
|
|
"""Load the standard all_types.capnp schema."""
|
|
directory = os.path.dirname(__file__)
|
|
return capnp.load(os.path.join(directory, "all_types.capnp"))
|
|
|
|
|
|
def test_set_bytes_get_bytes(all_types):
|
|
"""
|
|
Scenario 1: Set Byte -> Get Byte
|
|
Verify standard behavior: writing bytes results in reading bytes.
|
|
"""
|
|
msg = all_types.TestAllTypes.new_message()
|
|
input_data = b"hello_world"
|
|
|
|
# Set
|
|
msg.dataField = input_data
|
|
|
|
# Get
|
|
output_data = msg.dataField
|
|
|
|
# Verify
|
|
assert isinstance(output_data, bytes)
|
|
assert output_data == input_data
|
|
|
|
|
|
def test_set_view_get_bytes(all_types):
|
|
"""
|
|
Scenario 2: Set View -> Get Byte
|
|
Verify compatibility: Passing a memoryview sets the data,
|
|
but standard attribute access returns a bytes copy.
|
|
"""
|
|
msg = all_types.TestAllTypes.new_message()
|
|
|
|
# Create a memoryview source
|
|
raw_source = bytearray(b"view_source")
|
|
view = memoryview(raw_source)
|
|
|
|
# Set via memoryview
|
|
msg.dataField = view
|
|
|
|
# Get via standard attribute
|
|
output_data = msg.dataField
|
|
|
|
# Verify
|
|
assert isinstance(output_data, bytes)
|
|
assert output_data == b"view_source"
|
|
|
|
|
|
def test_set_bytes_get_view_and_modify(all_types):
|
|
"""
|
|
Scenario 3: Set Byte -> Get View
|
|
Verify the high-performance API get_data_as_view.
|
|
The view must be writable and modifications must reflect in the message.
|
|
"""
|
|
msg = all_types.TestAllTypes.new_message()
|
|
|
|
# Initial write
|
|
msg.dataField = b"ABCDE"
|
|
|
|
# Get view via new API
|
|
view = msg.get_data_as_view("dataField")
|
|
|
|
# Verify view properties
|
|
assert isinstance(view, memoryview)
|
|
assert view.readonly is False
|
|
assert view.tobytes() == b"ABCDE"
|
|
|
|
# Verify in-place modification
|
|
view[0] = ord("Z") # Change 'A' to 'Z'
|
|
|
|
# Verify modification is reflected in standard access
|
|
assert msg.dataField == b"ZBCDE"
|
|
|
|
|
|
def test_reader_vs_builder_view(all_types):
|
|
"""
|
|
Verify that Builder views are writable, but Reader views are read-only.
|
|
"""
|
|
# 1. Builder phase
|
|
builder = all_types.TestAllTypes.new_message()
|
|
builder.dataField = b"test_rw"
|
|
|
|
builder_view = builder.get_data_as_view("dataField")
|
|
assert builder_view.readonly is False
|
|
builder_view[0] = ord("T") # Modification allowed
|
|
|
|
# 2. Reader phase
|
|
reader = builder.as_reader()
|
|
|
|
# Standard Get
|
|
assert reader.dataField == b"Test_rw"
|
|
|
|
# Reader get_data_as_view
|
|
reader_view = reader.get_data_as_view("dataField")
|
|
assert isinstance(reader_view, memoryview)
|
|
assert reader_view.readonly is True
|
|
|
|
# Attempting to modify Reader view should raise TypeError
|
|
with pytest.raises(TypeError):
|
|
reader_view[0] = ord("X")
|
|
|
|
|
|
def test_nested_struct_data(all_types):
|
|
"""
|
|
Verify that get_data_as_view works correctly on nested structs.
|
|
"""
|
|
msg = all_types.TestAllTypes.new_message()
|
|
|
|
# Initialize nested struct
|
|
inner = msg.init("structField")
|
|
inner.int32Field = 100
|
|
inner.dataField = b"nested_data"
|
|
|
|
# 1. Verify standard access
|
|
assert msg.structField.dataField == b"nested_data"
|
|
|
|
# 2. Verify nested get_data_as_view
|
|
view = msg.structField.get_data_as_view("dataField")
|
|
|
|
assert isinstance(view, memoryview)
|
|
assert view.tobytes() == b"nested_data"
|
|
|
|
# Modify nested data
|
|
view[0] = ord("N")
|
|
assert msg.structField.dataField == b"Nested_data"
|
|
|
|
|
|
def test_corner_cases_values(all_types):
|
|
"""
|
|
Test edge cases: Empty bytes and binary data with nulls.
|
|
"""
|
|
msg = all_types.TestAllTypes.new_message()
|
|
|
|
# Case A: Empty Bytes
|
|
msg.dataField = b""
|
|
assert msg.dataField == b""
|
|
view = msg.get_data_as_view("dataField")
|
|
assert len(view) == 0
|
|
|
|
# Case B: Binary data containing null bytes
|
|
binary_data = b"\x00\xff\x00\x01"
|
|
msg.dataField = binary_data
|
|
assert msg.dataField == binary_data
|
|
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.
|
|
"""
|
|
msg = all_types.TestAllTypes.new_message()
|
|
msg.int32Field = 123
|
|
msg.textField = "I am text"
|
|
|
|
# Attempt on Int field
|
|
with pytest.raises(TypeError) as excinfo:
|
|
msg.get_data_as_view("int32Field")
|
|
assert "not a DATA field" in str(excinfo.value)
|
|
|
|
# Attempt on Text field
|
|
with pytest.raises(TypeError) as excinfo:
|
|
msg.get_data_as_view("textField")
|
|
assert "not a DATA field" in str(excinfo.value)
|
|
|
|
|
|
def test_error_missing_field(all_types):
|
|
"""
|
|
Test error handling: Accessing a non-existent field name.
|
|
"""
|
|
msg = all_types.TestAllTypes.new_message()
|
|
|
|
# Accessing a missing field should raise AttributeError (standard Python behavior)
|
|
with pytest.raises(AttributeError) as excinfo:
|
|
msg.get_data_as_view("non_existent_field")
|
|
|
|
# Optional: Verify the error message contains the field name
|
|
assert "non_existent_field" in str(excinfo.value)
|
|
|
|
|
|
def test_view_keeps_message_alive(all_types):
|
|
"""
|
|
Verify that a View keeps messages alive.
|
|
"""
|
|
msg = all_types.TestAllTypes.new_message()
|
|
expected_data = b"persistence_check"
|
|
msg.dataField = expected_data
|
|
|
|
initial_ref_count = sys.getrefcount(msg)
|
|
view = msg.get_data_as_view("dataField")
|
|
new_ref_count = sys.getrefcount(msg)
|
|
|
|
assert new_ref_count > initial_ref_count, (
|
|
f"View failed to hold reference to Message! (Old: {initial_ref_count}, New: {new_ref_count})"
|
|
)
|
|
print(f"\n[Ref Check] Success: Ref count increased from {initial_ref_count} to {new_ref_count}")
|
|
|
|
del msg
|
|
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()
|