Skip to content
Prev Previous commit
Next Next commit
_ctypes: pass and return structs and unions by value
Flip aggregate arguments and returns from the pointer / register-size
approximation to true by-value passing through the host_env `call` entry
point, and carry the driving layouts as host_env `CTypeLayout`.

- base.rs: `StgInfo.ffi_field_types: Vec<FfiType>` becomes
  `field_layouts: Vec<CTypeLayout>`, still built incrementally from the base
  class so struct inheritance is reflected. `StgInfo::to_ffi_type()` is
  replaced by a `type_layout(ty, &stg, vm)` helper that reads a type's own
  layout (aggregates from `field_layouts`, arrays recurse into the element
  type, simple types from their `_type_` code); it takes the already-borrowed
  `StgInfo` so it never re-locks the type. `CArgValue` gains an
  `Aggregate { layout, bytes }` variant lowering to `CallArg::Aggregate`, and
  `struct_union_paramfunc` snapshots the instance bytes into it (tag 'V').
- structure.rs / union.rs: collect each field's `type_layout` into
  `field_layouts` and store it on the finalized `StgInfo`.
- function.rs: `convert_object` passes a struct/union argument by value
  (snapshot bytes + argtype layout); a `byref()` result is still a pointer.
  `RetSpec` gains `Aggregate(CTypeLayout)`; `compute_ret_spec` returns it for
  a struct/union restype; `ctypes_callproc` lowers it to `CallRet::Aggregate`.
  `convert_raw_result` now drops the restype `StgInfo` read guard before
  constructing the result instance: instance construction write-locks the
  type's `StgInfo` to finalize it, which otherwise self-deadlocks against the
  held read guard (latent since the register-size return path, unreachable
  until a struct was actually returned by value).
- _ctypes.rs: a 'V' cparam now reprs as `<cparam 'V' at 0x..>` (the
  object-address default), matching PyCArg_repr; the aggregate `CArgValue`
  routes through that arm.
- extra_tests/snippets/stdlib_ctypes_byvalue.py: div/imaxdiv struct returns
  (8- and 16-byte), inet_ntoa struct and union arguments, with and without
  argtypes; output matches CPython. Also sorts the imports in the sibling
  stdlib_ctypes_calls.py snippet (ruff isort).

`_pack_` / `_swappedbytes_` layouts stay approximate — `CTypeLayout` carries
no explicit field offsets, as the previous libffi field-type path did not
either. host_env is untouched, so both `ctypes.rs` copies stay byte-identical.

test_ctypes is unchanged at run=322 skipped=58.

Assisted-by: Claude
  • Loading branch information
youknowone committed Jul 7, 2026
commit b5c88708c37fba8281b1693e673de45b54ea5d06
4 changes: 3 additions & 1 deletion crates/vm/src/stdlib/_ctypes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ pub(crate) mod _ctypes {
super::CArgValue::Int(v) => FfiValue::I32(*v),
super::CArgValue::Double(v) => FfiValue::F64(*v),
super::CArgValue::Pointer(v) => FfiValue::Pointer(*v),
// 'V' aggregates format via the object-address default arm below.
super::CArgValue::Aggregate { .. } => FfiValue::Pointer(0),
};

// Format value based on tag
Expand Down Expand Up @@ -197,7 +199,7 @@ pub(crate) mod _ctypes {
Ok(format!("<cparam '{tag_char}' ('\\x{byte:02x}')>"))
}
}
b'z' | b'Z' | b'P' | b'V' => {
b'z' | b'Z' | b'P' => {
// Pointer types
let ptr = match ffi_val {
FfiValue::Pointer(v) => v,
Expand Down
119 changes: 76 additions & 43 deletions crates/vm/src/stdlib/_ctypes/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ use num_traits::{Signed, ToPrimitive};
use rustpython_common::lock::PyRwLock;
use rustpython_common::wtf8::Wtf8;
use rustpython_host_env::ctypes::{
CTypeParamKind, FfiType, char_array_assignment_bytes, char_array_field_value,
ffi_type_for_layout, wchar_array_field_value, write_cow_bytes_at_offset,
CTypeLayout, char_array_assignment_bytes, char_array_field_value, wchar_array_field_value,
write_cow_bytes_at_offset,
};

// StgInfo - Storage information for ctypes types
Expand Down Expand Up @@ -99,8 +99,9 @@ pub struct StgInfo {
// Byte order (for _swappedbytes_)
pub big_endian: bool, // true if big endian, false if little endian

// FFI field types for structure/union passing (inherited from base class)
pub ffi_field_types: Vec<FfiType>,
// Call layouts of the struct/union fields, in declaration order (inherited
// from base class). Drives by-value aggregate passing.
pub field_layouts: Vec<CTypeLayout>,

// Cached pointer type (non-inheritable via descriptor)
pub pointer_type: Option<PyObjectRef>,
Expand All @@ -127,7 +128,7 @@ impl core::fmt::Debug for StgInfo {
.field("shape", &self.shape)
.field("paramfunc", &self.paramfunc)
.field("big_endian", &self.big_endian)
.field("ffi_field_types", &self.ffi_field_types.len())
.field("field_layouts", &self.field_layouts.len())
.finish()
}
}
Expand All @@ -147,7 +148,7 @@ impl Default for StgInfo {
shape: Vec::new(),
paramfunc: ParamFunc::None,
big_endian: cfg!(target_endian = "big"), // native endian by default
ffi_field_types: Vec::new(),
field_layouts: Vec::new(),
pointer_type: None,
}
}
Expand All @@ -168,7 +169,7 @@ impl StgInfo {
shape: Vec::new(),
paramfunc: ParamFunc::None,
big_endian: cfg!(target_endian = "big"), // native endian by default
ffi_field_types: Vec::new(),
field_layouts: Vec::new(),
pointer_type: None,
}
}
Expand Down Expand Up @@ -220,30 +221,11 @@ impl StgInfo {
shape,
paramfunc: ParamFunc::Array,
big_endian: cfg!(target_endian = "big"), // native endian by default
ffi_field_types: Vec::new(),
field_layouts: Vec::new(),
pointer_type: None,
}
}

/// Get libffi type for this StgInfo
/// Note: For very large types, returns pointer type to avoid overflow
pub fn to_ffi_type(&self) -> FfiType {
let kind = match self.paramfunc {
ParamFunc::Structure => CTypeParamKind::Structure,
ParamFunc::Union => CTypeParamKind::Union,
ParamFunc::Array => CTypeParamKind::Array,
ParamFunc::Pointer => CTypeParamKind::Pointer,
_ => CTypeParamKind::Simple,
};
ffi_type_for_layout(
kind,
&self.ffi_field_types,
self.size,
self.length,
self.format.as_deref(),
)
}

/// Check if this type is finalized (cannot set _fields_ again)
pub fn is_final(&self) -> bool {
self.flags.contains(StgInfoFlags::DICTFLAG_FINAL)
Expand All @@ -255,6 +237,44 @@ impl StgInfo {
}
}

/// Build the host_env call layout for a ctypes type from its already-borrowed
/// `StgInfo`. Aggregate layouts come straight from the type's `field_layouts`
/// (built incrementally from the base class, so struct inheritance is
/// reflected); array elements recurse into the element type; simple types read
/// their `_type_` code. The caller passes the borrowed `stg` so this never
/// re-locks `ty`'s own type data.
pub(super) fn type_layout(ty: &Py<PyType>, stg: &StgInfo, vm: &VirtualMachine) -> CTypeLayout {
match stg.paramfunc {
ParamFunc::Structure => CTypeLayout::Struct {
fields: stg.field_layouts.clone(),
size: stg.size,
},
ParamFunc::Union => CTypeLayout::Union {
fields: stg.field_layouts.clone(),
size: stg.size,
},
ParamFunc::Array => {
let element = stg
.element_type
.as_ref()
.and_then(|et| et.stg_info_opt().map(|et_stg| type_layout(et, &et_stg, vm)))
.unwrap_or(CTypeLayout::Opaque {
size: stg.element_size,
});
CTypeLayout::Array {
element: Box::new(element),
length: stg.length,
size: stg.size,
}
}
ParamFunc::Pointer => CTypeLayout::Pointer,
ParamFunc::Simple | ParamFunc::None => ty
.type_code(vm)
.and_then(|code| code.chars().next())
.map_or(CTypeLayout::Opaque { size: stg.size }, CTypeLayout::Simple),
}
}

/// __pointer_type__ getter for ctypes metaclasses.
/// Reads from StgInfo.pointer_type (non-inheritable).
pub(super) fn pointer_type_get(zelf: &Py<PyType>, vm: &VirtualMachine) -> PyResult {
Expand Down Expand Up @@ -1884,28 +1904,30 @@ fn pointer_paramfunc(obj: &PyObject, vm: &VirtualMachine) -> PyResult<CArgObject

/// StructUnionType_paramfunc (for both Structure and Union)
fn struct_union_paramfunc(obj: &PyObject, stg_info: &StgInfo, _vm: &VirtualMachine) -> CArgObject {
// Get buffer pointer
// For large structs (> sizeof(void*)), we'd need to allocate and copy.
// For now, just point to buffer directly and keep obj reference for memory safety.
let buffer = if let Some(cdata) = obj.downcast_ref::<PyCData>() {
cdata.buffer.read()
// Snapshot the instance bytes and pass the aggregate by value. The layout
// is built here from the already-borrowed `stg_info` to avoid re-locking.
let (bytes, size) = if let Some(cdata) = obj.downcast_ref::<PyCData>() {
let buffer = cdata.buffer.read();
(buffer.to_vec(), buffer.len())
} else {
return CArgObject {
tag: b'V',
value: CArgValue::pointer(0),
obj: obj.to_owned(),
keep: None,
size: stg_info.size,
offset: 0,
};
(Vec::new(), stg_info.size)
};

let ptr_val = buffer.as_ptr() as usize;
let size = buffer.len();
let layout = if matches!(stg_info.paramfunc, ParamFunc::Union) {
CTypeLayout::Union {
fields: stg_info.field_layouts.clone(),
size: stg_info.size,
}
} else {
CTypeLayout::Struct {
fields: stg_info.field_layouts.clone(),
size: stg_info.size,
}
};

CArgObject {
tag: b'V',
value: CArgValue::pointer(ptr_val),
value: CArgValue::aggregate(layout, bytes),
obj: obj.to_owned(),
keep: None,
size,
Expand All @@ -1929,6 +1951,8 @@ pub enum CArgValue {
Double(f64),
/// Address-valued argument (pointer decay, byref, buffer copies, NULL = 0).
Pointer(usize),
/// By-value aggregate: its call layout plus a snapshot of its bytes.
Aggregate { layout: CTypeLayout, bytes: Vec<u8> },
}

impl CArgValue {
Expand All @@ -1944,6 +1968,11 @@ impl CArgValue {
}
}

/// Snapshot an aggregate value from its call layout and buffer bytes.
pub(super) fn aggregate(layout: CTypeLayout, bytes: Vec<u8>) -> Self {
Self::Aggregate { layout, bytes }
}

/// Lower to a [`CallArg`], borrowing `code_buf` for the code's `&str`.
pub(super) fn as_call_arg<'a>(
&'a self,
Expand All @@ -1958,6 +1987,10 @@ impl CArgValue {
Self::Int(value) => CallArg::Int(*value),
Self::Double(value) => CallArg::Double(*value),
Self::Pointer(value) => CallArg::Pointer(*value),
Self::Aggregate { layout, bytes } => CallArg::Aggregate {
layout,
buffer: bytes,
},
}
}
}
Expand Down
67 changes: 49 additions & 18 deletions crates/vm/src/stdlib/_ctypes/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

use super::{
_ctypes::CArgObject,
PyCArray, PyCData, PyCPointer, PyCStructure, StgInfo,
PyCArray, PyCData, PyCPointer, PyCStructure, PyCUnion, StgInfo,
base::{CArgValue, CDATA_BUFFER_METHODS, ParamFunc, StgInfoFlags},
simple::PyCSimple,
};
Expand All @@ -25,9 +25,9 @@ use rustpython_common::lock::PyRwLock;
#[cfg(windows)]
use rustpython_host_env::ctypes::ComMethodError;
use rustpython_host_env::ctypes::{
CallError, CallOptions, CallRet, CallValue, FfiCif, FfiCodePtr, FfiType, RawMemoryView,
RawMemoryViewError, StringAtError, call, ffi_pointer_type, ffi_type_from_code, ffi_void_type,
has_pointer_width, offset_address, pointer_bytes, pointer_format, pointer_size,
CTypeLayout, CallError, CallOptions, CallRet, CallValue, FfiCif, FfiCodePtr, FfiType,
RawMemoryView, RawMemoryViewError, StringAtError, call, ffi_pointer_type, ffi_type_from_code,
ffi_void_type, has_pointer_width, offset_address, pointer_bytes, pointer_format, pointer_size,
simple_type_is_pointer, write_pointer_to_buffer_at, write_prefix_limited,
};

Expand Down Expand Up @@ -220,6 +220,7 @@ impl ArgumentType for PyTypeRef {
let type_code = if self.fast_issubclass(CArgObject::static_type())
|| self.fast_issubclass(PyCPointer::static_type())
|| self.fast_issubclass(PyCStructure::static_type())
|| self.fast_issubclass(PyCUnion::static_type())
{
None
} else {
Expand Down Expand Up @@ -266,8 +267,20 @@ impl ArgumentType for PyTypeRef {
return Ok((convert_to_pointer(&converted, vm)?, None));
}

// For structure types, convert to pointer to structure
if self.fast_issubclass(PyCStructure::static_type()) {
// For structure/union types, pass the aggregate by value: snapshot the
// instance bytes and build its call layout from the argtype. A byref()
// result is a CArgObject and was already handled above (stays a pointer).
if self.fast_issubclass(PyCStructure::static_type())
|| self.fast_issubclass(PyCUnion::static_type())
{
if let Some(cdata) = converted.downcast_ref::<PyCData>() {
let bytes = cdata.buffer.read().to_vec();
let layout = self.stg_info_opt().map_or_else(
|| CTypeLayout::Opaque { size: bytes.len() },
|stg| super::base::type_layout(self, &stg, vm),
);
return Ok((CArgValue::aggregate(layout, bytes), None));
}
return Ok((convert_to_pointer(&converted, vm)?, None));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down Expand Up @@ -910,9 +923,10 @@ enum RetSpec {
/// Pointer-valued return (a `TYPEFLAG_ISPOINTER` restype, or an oversized
/// by-value struct approximated as a pointer-sized register).
Pointer,
/// A scalar retrieved as the given ctypes simple-type code. Small struct
/// returns are approximated as `'i'` (<=4 bytes) or `'q'` (<=8 bytes).
/// A scalar retrieved as the given ctypes simple-type code.
Code(char),
/// A by-value aggregate (struct/union) return with the given call layout.
Aggregate(CTypeLayout),
}

/// Call information extracted from PyCFuncPtr (argtypes, restype, etc.)
Expand Down Expand Up @@ -959,15 +973,25 @@ fn compute_ret_spec(
};
}

// Structure/Array (StgInfo, no _type_): size-approximated register return
// Structure/Union (StgInfo, no _type_): returned by value as an aggregate.
// The layout is built from the held guard to avoid re-locking the type.
if let Some(stg_info) = restype_type.stg_info_opt() {
let size = stg_info.size;
return if size <= 4 {
RetSpec::Code('i')
} else if size <= 8 {
RetSpec::Code('q')
} else {
RetSpec::Pointer
return match stg_info.paramfunc {
ParamFunc::Structure | ParamFunc::Union => {
RetSpec::Aggregate(super::base::type_layout(&restype_type, &stg_info, vm))
}
// Any other aggregate-ish StgInfo without a code: size-approximated
// register return, as before.
_ => {
let size = stg_info.size;
if size <= 4 {
RetSpec::Code('i')
} else if size <= 8 {
RetSpec::Code('q')
} else {
RetSpec::Pointer
}
}
};
}

Expand Down Expand Up @@ -1308,6 +1332,7 @@ fn ctypes_callproc(
RetSpec::Void => CallRet::Void,
RetSpec::Pointer => CallRet::Pointer,
RetSpec::Code(code) => CallRet::Code(code.encode_utf8(&mut ret_code_buf)),
RetSpec::Aggregate(layout) => CallRet::Aggregate(layout),
};

call(addr, &call_args, call_ret, options)
Expand Down Expand Up @@ -1410,6 +1435,12 @@ fn convert_raw_result(
}

let info = stg_info.unwrap();
// Extract what's needed and release the read guard before constructing any
// instance below: instance construction write-locks the type's StgInfo (to
// finalize it), which would self-deadlock against a held read guard.
let is_pointer_type = info.flags.contains(StgInfoFlags::TYPEFLAG_ISPOINTER);
let has_proto = info.proto.is_some();
drop(info);

// py_object: interpret return value as PyObject* and materialize it.
if let Ok(type_attr) = restype_type
Expand Down Expand Up @@ -1442,8 +1473,8 @@ fn convert_raw_result(
// This handles POINTER(T), Structure, Array, etc.

// Special handling for POINTER(T) types - set pointer value directly
if info.flags.contains(StgInfoFlags::TYPEFLAG_ISPOINTER)
&& info.proto.is_some()
if is_pointer_type
&& has_proto
&& let CallValue::Pointer(ptr) = result
&& let Ok(instance) = restype_type.as_object().call((), vm)
{
Expand Down
Loading