From 7f672f97556d21fa4392d2656a46f0e4a5f7565d Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 11:53:54 +0800 Subject: [PATCH 01/12] Prepare Python value lifecycle and apartment mapping Behavior-preserving groundwork for clearer runtime errors: - Route every DynWinRTValue construction through DynWinRTValue::new so the value can carry lifecycle state without touching each call site again. - Resolve apartment_type through one ro_init_type() mapping and a named default, shared by RoApartment and ro_initialize, instead of two inline 0/1 matches. - Emit as_interface() from a single codegen template instead of three copies. Generated output is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/src/async_runtime.rs | 6 +- bindings/py/src/delegate_method.rs | 2 +- bindings/py/src/implementation.rs | 6 +- bindings/py/src/runtime.rs | 149 ++++++++++-------- .../codegen/winrt/python/generator/class.rs | 6 +- .../src/codegen/winrt/python/generator/mod.rs | 5 + .../codegen/winrt/python/generator/types.rs | 3 +- 7 files changed, 94 insertions(+), 83 deletions(-) diff --git a/bindings/py/src/async_runtime.rs b/bindings/py/src/async_runtime.rs index 24a1de57..f8d2ce24 100644 --- a/bindings/py/src/async_runtime.rs +++ b/bindings/py/src/async_runtime.rs @@ -474,7 +474,7 @@ impl ProgressDispatcher { return Ok(()); } - let raw = Py::new(py, DynWinRTValue(value))?; + let raw = Py::new(py, DynWinRTValue::new(value))?; let context = self.callback_context.call_method0(py, "copy")?; let context_run = context.getattr(py, "run")?; self.event_loop.call_method1( @@ -585,7 +585,7 @@ impl AsyncOperation { let raw_future = pyo3_async_runtimes::tokio::future_into_py(py, async move { let result = winrt_future.await; let result = result.map_err(map_dynwinrt_error)?; - Ok(DynWinRTValue(result)) + Ok(DynWinRTValue::new(result)) })?; let converter = self.converter.clone_ref(py); @@ -632,7 +632,7 @@ impl AsyncOperation { *state = ExecutionState::Idle; } - let raw = Py::new(py, DynWinRTValue(result?))?; + let raw = Py::new(py, DynWinRTValue::new(result?))?; self.converter.call1(py, (raw,)) } diff --git a/bindings/py/src/delegate_method.rs b/bindings/py/src/delegate_method.rs index fbfc14bb..50e54f80 100644 --- a/bindings/py/src/delegate_method.rs +++ b/bindings/py/src/delegate_method.rs @@ -61,7 +61,7 @@ impl DynWinRTDelegateMethod { }; let args = args.into_iter().map(|arg| arg.0).collect::>(); (self.call.0)(object, &args) - .map(|outputs| outputs.into_iter().map(DynWinRTValue).collect()) + .map(|outputs| outputs.into_iter().map(DynWinRTValue::new).collect()) .map_err(map_windows_error) } } diff --git a/bindings/py/src/implementation.rs b/bindings/py/src/implementation.rs index dbc65ced..11ed4329 100644 --- a/bindings/py/src/implementation.rs +++ b/bindings/py/src/implementation.rs @@ -206,7 +206,7 @@ impl CallbackCell { let result = (|| -> PyResult> { let inputs = args .iter() - .map(|value| Py::new(py, DynWinRTValue(value.clone()))) + .map(|value| Py::new(py, DynWinRTValue::new(value.clone()))) .collect::>>()?; let inputs = PyList::new(py, inputs)?; let outputs = callback.call1(py, (interface_index, vtable_index, inputs))?; @@ -353,7 +353,7 @@ impl DynWinRTImplementation { self.with_native(|native| { native .to_value() - .map(DynWinRTValue) + .map(DynWinRTValue::new) .map_err(map_windows_error) }) } @@ -572,7 +572,7 @@ mod tests { globals .set_item( "result", - DynWinRTValue(dynwinrt::WinRTValue::HString("finished".into())), + DynWinRTValue::new(dynwinrt::WinRTValue::HString("finished".into())), ) .unwrap(); let function = py diff --git a/bindings/py/src/runtime.rs b/bindings/py/src/runtime.rs index 3b717856..12f201f2 100644 --- a/bindings/py/src/runtime.rs +++ b/bindings/py/src/runtime.rs @@ -7,6 +7,9 @@ use dynwinrt; use pyo3::exceptions::{PyIndexError, PyOverflowError, PyRuntimeError, PyTypeError}; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict, PyList}; +use windows::Win32::System::WinRT::{ + RO_INIT_MULTITHREADED, RO_INIT_SINGLETHREADED, RO_INIT_TYPE, RoInitialize, +}; use windows::core::{GUID, HSTRING, IUnknown, Interface}; use crate::errors::{map_dynwinrt_error, map_dynwinrt_error_with_context, map_windows_error}; @@ -128,6 +131,19 @@ pub struct RoApartment { active: bool, } +/// `apartment_type` used when Python omits it: the multithreaded apartment. +const DEFAULT_APARTMENT_TYPE: i32 = RO_INIT_MULTITHREADED.0; + +/// The `RoInitialize` model for a Python `apartment_type`. Values other than +/// `RO_INIT_SINGLETHREADED` keep their historical multithreaded meaning. +fn ro_init_type(apartment_type: i32) -> RO_INIT_TYPE { + if apartment_type == RO_INIT_SINGLETHREADED.0 { + RO_INIT_SINGLETHREADED + } else { + RO_INIT_MULTITHREADED + } +} + impl RoApartment { fn initialize(&mut self) -> PyResult<()> { if self.active { @@ -135,14 +151,7 @@ impl RoApartment { "the COM apartment context is already active", )); } - use windows::Win32::System::WinRT::{ - RO_INIT_MULTITHREADED, RO_INIT_SINGLETHREADED, RoInitialize, - }; - let init_type = match self.apartment_type { - 0 => RO_INIT_SINGLETHREADED, - _ => RO_INIT_MULTITHREADED, - }; - unsafe { RoInitialize(init_type) }.map_err(map_windows_error)?; + unsafe { RoInitialize(ro_init_type(self.apartment_type)) }.map_err(map_windows_error)?; self.active = true; Ok(()) } @@ -167,7 +176,7 @@ impl RoApartment { #[pyo3(signature = (apartment_type=None))] fn new(apartment_type: Option) -> Self { Self { - apartment_type: apartment_type.unwrap_or(1), + apartment_type: apartment_type.unwrap_or(DEFAULT_APARTMENT_TYPE), active: false, } } @@ -208,13 +217,7 @@ pub fn init_winappsdk(major: u32, minor: u32) -> PyResult { #[pyfunction] pub fn ro_initialize(apartment_type: Option) -> PyResult<()> { - use windows::Win32::System::WinRT::{ - RO_INIT_MULTITHREADED, RO_INIT_SINGLETHREADED, RoInitialize, - }; - let init_type = match apartment_type.unwrap_or(1) { - 0 => RO_INIT_SINGLETHREADED, - _ => RO_INIT_MULTITHREADED, - }; + let init_type = ro_init_type(apartment_type.unwrap_or(DEFAULT_APARTMENT_TYPE)); unsafe { RoInitialize(init_type) }.map_err(map_windows_error) } @@ -960,9 +963,9 @@ impl DynWinRTMethodHandle { let wrt_args: Vec = args.iter().map(|a| a.0.clone()).collect(); let results = self.0.invoke(raw, &wrt_args).map_err(map_dynwinrt_error)?; if results.is_empty() { - Ok(DynWinRTValue(dynwinrt::WinRTValue::I32(0))) + Ok(DynWinRTValue::new(dynwinrt::WinRTValue::I32(0))) } else { - Ok(DynWinRTValue(results.into_iter().next().unwrap())) + Ok(DynWinRTValue::new(results.into_iter().next().unwrap())) } } @@ -1015,9 +1018,9 @@ impl DynWinRTMethodHandle { .0 .map_err(map_dynwinrt_error)?; if results.is_empty() { - Ok(DynWinRTValue(dynwinrt::WinRTValue::I32(0))) + Ok(DynWinRTValue::new(dynwinrt::WinRTValue::I32(0))) } else { - Ok(DynWinRTValue( + Ok(DynWinRTValue::new( results .into_iter() .next() @@ -1043,7 +1046,7 @@ impl DynWinRTMethodHandle { }; let wrt_args: Vec = args.iter().map(|a| a.0.clone()).collect(); let results = self.0.invoke(raw, &wrt_args).map_err(map_dynwinrt_error)?; - Ok(results.into_iter().map(DynWinRTValue).collect()) + Ok(results.into_iter().map(DynWinRTValue::new).collect()) } /// Invoke a WinRT composable factory with a runtime-provided outer host. @@ -1072,7 +1075,7 @@ impl DynWinRTMethodHandle { instance_output_index, agile, ) - .map(DynWinRTValue) + .map(DynWinRTValue::new) .map_err(map_dynwinrt_error) } @@ -1120,7 +1123,7 @@ impl DynWinRTMethodHandle { agile, overrides, ) - .map(DynWinRTValue) + .map(DynWinRTValue::new) .map_err(map_dynwinrt_error) } @@ -1169,7 +1172,7 @@ impl DynWinRTMethodHandle { .as_raw(); self.0 .call_getter_object(raw) - .map(DynWinRTValue) + .map(DynWinRTValue::new) .map_err(map_dynwinrt_error) } @@ -1184,7 +1187,7 @@ impl DynWinRTMethodHandle { .0 .invoke(raw, &[dynwinrt::WinRTValue::HString(HSTRING::from(arg))]) .map_err(map_dynwinrt_error)?; - Ok(DynWinRTValue(results.into_iter().next().ok_or_else( + Ok(DynWinRTValue::new(results.into_iter().next().ok_or_else( || PyRuntimeError::new_err("invoke_hstring: no result"), )?)) } @@ -1200,7 +1203,7 @@ impl DynWinRTMethodHandle { .0 .invoke(raw, &[dynwinrt::WinRTValue::I32(arg)]) .map_err(map_dynwinrt_error)?; - Ok(DynWinRTValue(results.into_iter().next().ok_or_else( + Ok(DynWinRTValue::new(results.into_iter().next().ok_or_else( || PyRuntimeError::new_err("invoke_i32: no result"), )?)) } @@ -1214,13 +1217,19 @@ impl DynWinRTMethodHandle { #[derive(Clone)] pub struct DynWinRTValue(pub(crate) dynwinrt::WinRTValue); +impl DynWinRTValue { + pub(crate) fn new(value: dynwinrt::WinRTValue) -> Self { + Self(value) + } +} + #[pymethods] impl DynWinRTValue { #[staticmethod] fn activation_factory(name: String) -> PyResult { WINUI_MODULES .activation_factory(&HSTRING::from(name)) - .map(DynWinRTValue) + .map(DynWinRTValue::new) .map_err(map_dynwinrt_error) } @@ -1237,7 +1246,7 @@ impl DynWinRTValue { )); }; dynwinrt::copy_to_ibuffer(&bytes) - .map(DynWinRTValue) + .map(DynWinRTValue::new) .map_err(map_dynwinrt_error) } @@ -1265,7 +1274,7 @@ impl DynWinRTValue { .transpose()?; WINUI_MODULES .create_xaml_application(&provider, callback.as_ref()) - .map(DynWinRTValue) + .map(DynWinRTValue::new) .map_err(map_dynwinrt_error) } @@ -1273,71 +1282,71 @@ impl DynWinRTValue { #[staticmethod] fn from_bool(value: bool) -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::Bool(value)) + DynWinRTValue::new(dynwinrt::WinRTValue::Bool(value)) } #[staticmethod] fn from_i8(value: i32) -> PyResult { - Ok(DynWinRTValue(dynwinrt::WinRTValue::I8(checked_i8( + Ok(DynWinRTValue::new(dynwinrt::WinRTValue::I8(checked_i8( value, "from_i8", )?))) } #[staticmethod] fn from_u8(value: u32) -> PyResult { - Ok(DynWinRTValue(dynwinrt::WinRTValue::U8(checked_u8( + Ok(DynWinRTValue::new(dynwinrt::WinRTValue::U8(checked_u8( value, "from_u8", )?))) } #[staticmethod] fn from_i16(value: i32) -> PyResult { - Ok(DynWinRTValue(dynwinrt::WinRTValue::I16(checked_i16( + Ok(DynWinRTValue::new(dynwinrt::WinRTValue::I16(checked_i16( value, "from_i16", )?))) } #[staticmethod] fn from_u16(value: u32) -> PyResult { - Ok(DynWinRTValue(dynwinrt::WinRTValue::U16(checked_u16( + Ok(DynWinRTValue::new(dynwinrt::WinRTValue::U16(checked_u16( value, "from_u16", )?))) } #[staticmethod] fn from_i32(value: i32) -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::I32(value)) + DynWinRTValue::new(dynwinrt::WinRTValue::I32(value)) } #[staticmethod] fn from_hresult(value: i32) -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::HResult(windows::core::HRESULT(value))) + DynWinRTValue::new(dynwinrt::WinRTValue::HResult(windows::core::HRESULT(value))) } #[staticmethod] fn from_u32(value: u32) -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::U32(value)) + DynWinRTValue::new(dynwinrt::WinRTValue::U32(value)) } #[staticmethod] fn from_i64(value: i64) -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::I64(value)) + DynWinRTValue::new(dynwinrt::WinRTValue::I64(value)) } #[staticmethod] fn from_u64(value: u64) -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::U64(value)) + DynWinRTValue::new(dynwinrt::WinRTValue::U64(value)) } #[staticmethod] fn from_f32(value: f32) -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::F32(value)) + DynWinRTValue::new(dynwinrt::WinRTValue::F32(value)) } #[staticmethod] fn from_f64(value: f64) -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::F64(value)) + DynWinRTValue::new(dynwinrt::WinRTValue::F64(value)) } #[staticmethod] fn from_hstring(value: String) -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::HString(HSTRING::from(value))) + DynWinRTValue::new(dynwinrt::WinRTValue::HString(HSTRING::from(value))) } #[staticmethod] fn from_guid(value: &WinGUID) -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::Guid(value.0)) + DynWinRTValue::new(dynwinrt::WinRTValue::Guid(value.0)) } #[staticmethod] fn null_value() -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::Null) + DynWinRTValue::new(dynwinrt::WinRTValue::Null) } /// Create an enum value within the enum's declared i32 or u32 range. @@ -1346,14 +1355,14 @@ impl DynWinRTValue { enum_type .0 .enum_value(value) - .map(DynWinRTValue) + .map(DynWinRTValue::new) .map_err(map_dynwinrt_error) } #[staticmethod] fn box_reference(value: &DynWinRTValue, value_type: &DynWinRTType) -> PyResult { dynwinrt::box_ireference(value.0.clone(), value_type.0.clone()) - .map(DynWinRTValue) + .map(DynWinRTValue::new) .map_err(map_dynwinrt_error) } @@ -1382,7 +1391,7 @@ impl DynWinRTValue { let wrt_items: Vec = items.iter().map(|i| i.0.clone()).collect(); let vector = dynwinrt::vector::create_vector_from_values(&wrt_items, &element_type.0, iids) .map_err(map_dynwinrt_error)?; - Ok(DynWinRTValue(dynwinrt::WinRTValue::Object(vector))) + Ok(DynWinRTValue::new(dynwinrt::WinRTValue::Object(vector))) } /// Create an IMap from parallel key/value lists. @@ -1406,18 +1415,18 @@ impl DynWinRTValue { .collect(); let map = dynwinrt::map::create_map_from_values(&entries, &key_type.0, &value_type.0, iids) .map_err(map_dynwinrt_error)?; - Ok(DynWinRTValue(dynwinrt::WinRTValue::Object(map))) + Ok(DynWinRTValue::new(dynwinrt::WinRTValue::Object(map))) } /// Await an async WinRT operation (blocks the current thread). /// Releases the Python GIL while waiting so other threads can proceed. fn wait(&self, py: Python<'_>) -> PyResult { - super::async_runtime::wait_for_async(&self.0, py).map(DynWinRTValue) + super::async_runtime::wait_for_async(&self.0, py).map(DynWinRTValue::new) } fn _get_async_results(&self) -> PyResult { dynwinrt::get_async_results(&self.0) - .map(DynWinRTValue) + .map(DynWinRTValue::new) .map_err(map_dynwinrt_error) } @@ -1458,7 +1467,7 @@ impl DynWinRTValue { let progress_cb: dynwinrt::ProgressCallback = Box::new(move |val: dynwinrt::WinRTValue| { Python::attach(|py| { let result = (|| -> PyResult<()> { - let py_val = Py::new(py, DynWinRTValue(val))?; + let py_val = Py::new(py, DynWinRTValue::new(val))?; callback.call1(py, (py_val,))?; Ok(()) })(); @@ -1654,7 +1663,7 @@ impl DynWinRTValue { fn cast(&self, iid: &WinGUID) -> PyResult { self.0 .cast(&iid.0) - .map(DynWinRTValue) + .map(DynWinRTValue::new) .map_err(map_dynwinrt_error) } @@ -1680,7 +1689,7 @@ impl DynWinRTValue { .ok_or_else(|| PyRuntimeError::new_err("activate: not an Object"))? .as_raw(); let result = method.call_dynamic(raw, &[]).map_err(map_windows_error)?; - Ok(DynWinRTValue(result.into_iter().next().ok_or_else( + Ok(DynWinRTValue::new(result.into_iter().next().ok_or_else( || PyRuntimeError::new_err("activate: no result"), )?)) } @@ -1700,7 +1709,7 @@ impl DynWinRTValue { let result = method .call_dynamic(obj_raw, &[]) .map_err(map_windows_error)?; - Ok(DynWinRTValue(result.into_iter().next().unwrap())) + Ok(DynWinRTValue::new(result.into_iter().next().unwrap())) } /// Call a method with one arg and one out param. @@ -1723,7 +1732,7 @@ impl DynWinRTValue { let result = method .call_dynamic(obj_raw, &[v1.0.clone()]) .map_err(map_windows_error)?; - Ok(DynWinRTValue(result.into_iter().next().unwrap())) + Ok(DynWinRTValue::new(result.into_iter().next().unwrap())) } /// General-purpose method call with explicit types and args. @@ -1759,9 +1768,9 @@ impl DynWinRTValue { .map_err(map_windows_error)?; if result.is_empty() { - Ok(DynWinRTValue(dynwinrt::WinRTValue::I32(0))) + Ok(DynWinRTValue::new(dynwinrt::WinRTValue::I32(0))) } else { - Ok(DynWinRTValue(result.into_iter().next().unwrap())) + Ok(DynWinRTValue::new(result.into_iter().next().unwrap())) } } @@ -1809,14 +1818,14 @@ impl DynWinRTArray { let index = checked_index(index)?; self.0 .try_get(index) - .map(DynWinRTValue) + .map(DynWinRTValue::new) .map_err(map_dynwinrt_error) } /// Convert all elements to a list of DynWinRTValue. fn to_values(&self) -> Vec { (0..self.0.len()) - .map(|i| DynWinRTValue(self.0.get(i))) + .map(|i| DynWinRTValue::new(self.0.get(i))) .collect() } @@ -2064,7 +2073,7 @@ impl DynWinRTArray { /// Wrap as DynWinRTValue::Array for passing to call(). fn to_value(&self) -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::Array(self.0.clone())) + DynWinRTValue::new(dynwinrt::WinRTValue::Array(self.0.clone())) } fn __repr__(&self) -> String { @@ -2285,8 +2294,8 @@ impl DynWinRTStruct { fn get_object(&self, index: i64) -> PyResult { let index = checked_index(index)?; match self.0.get_field_object(index).map_err(map_dynwinrt_error)? { - Some(object) => Ok(DynWinRTValue(dynwinrt::WinRTValue::Object(object))), - None => Ok(DynWinRTValue(dynwinrt::WinRTValue::Null)), + Some(object) => Ok(DynWinRTValue::new(dynwinrt::WinRTValue::Object(object))), + None => Ok(DynWinRTValue::new(dynwinrt::WinRTValue::Null)), } } @@ -2309,7 +2318,7 @@ impl DynWinRTStruct { /// Wrap as DynWinRTValue::Struct for passing to call(). fn to_value(&self) -> DynWinRTValue { - DynWinRTValue(dynwinrt::WinRTValue::Struct(self.0.clone())) + DynWinRTValue::new(dynwinrt::WinRTValue::Struct(self.0.clone())) } fn __repr__(&self) -> String { @@ -2339,7 +2348,7 @@ fn create_python_delegate( let py_args = args .iter() .map(|arg| { - Ok(DynWinRTValue(arg.clone()) + Ok(DynWinRTValue::new(arg.clone()) .into_pyobject(py)? .into_any() .unbind()) @@ -2383,7 +2392,7 @@ impl DynWinRtDelegate { /// Get the delegate as a DynWinRTValue for passing to WinRT methods. fn to_value(&self) -> DynWinRTValue { - DynWinRTValue(self.0.clone()) + DynWinRTValue::new(self.0.clone()) } fn __repr__(&self) -> String { @@ -2465,7 +2474,7 @@ impl DynWinRtElementFactory { ) }; let result = (|| -> PyResult { - let argument = Py::new(py, DynWinRTValue(args.clone()))?; + let argument = Py::new(py, DynWinRTValue::new(args.clone()))?; let result = callback.call1(py, (argument,))?; let value = result.extract::>(py)?; value.0.cast(&element_iid).map_err(map_dynwinrt_error) @@ -2497,7 +2506,7 @@ impl DynWinRtElementFactory { ) }; let result = (|| -> PyResult<()> { - let argument = Py::new(py, DynWinRTValue(args.clone()))?; + let argument = Py::new(py, DynWinRTValue::new(args.clone()))?; callback.call1(py, (argument,))?; Ok(()) })(); @@ -2518,7 +2527,7 @@ impl DynWinRtElementFactory { } fn to_value(&self) -> DynWinRTValue { - DynWinRTValue(self.value.clone()) + DynWinRTValue::new(self.value.clone()) } fn release_callbacks(&self) -> PyResult<()> { @@ -2660,7 +2669,7 @@ mod tests { None, ) .unwrap(); - let receiver = DynWinRTValue(owner.to_value().unwrap().cast(&iid).unwrap()); + let receiver = DynWinRTValue::new(owner.to_value().unwrap().cast(&iid).unwrap()); let method = DynWinRTMethodHandle(interface.method(6).unwrap()); let direct = method.invoke(receiver.clone(), vec![]).unwrap(); let detached = method.invoke_detached(py, receiver, vec![]).unwrap(); @@ -2671,7 +2680,7 @@ mod tests { )); } let invalid = method - .invoke_detached(py, DynWinRTValue(dynwinrt::WinRTValue::I32(0)), vec![]) + .invoke_detached(py, DynWinRTValue::new(dynwinrt::WinRTValue::I32(0)), vec![]) .err() .expect("non-object receiver must be rejected"); assert!(invalid.is_instance_of::(py)); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs index 1a670f54..8d20e9e7 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -763,8 +763,7 @@ pub fn generate_class( || !class.required_interfaces.is_empty() { out.push('\n'); - out.push_str(" def as_interface(self, interface_class):\n"); - out.push_str(" return interface_class.from_value(self._obj)\n"); + out.push_str(AS_INTERFACE_METHOD); } if winui::is_dispatcher_queue(class) { @@ -890,8 +889,7 @@ pub fn generate_class( " return cls._from_native(obj.cast(IID_{symbol}))\n" )); out.push('\n'); - out.push_str(" def as_interface(self, interface_class):\n"); - out.push_str(" return interface_class.from_value(self._obj)\n"); + out.push_str(AS_INTERFACE_METHOD); for methods in crate::codegen::winrt::python::overloads::grouped_methods( reorder_getters_before_setters(&req_iface.methods), ) { diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs index 76351781..7bf2f758 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -130,6 +130,11 @@ def _dynwinrt_can_cast(value, iid): return True \n"; +/// `as_interface()`, emitted for every generated class and interface view. +const AS_INTERFACE_METHOD: &str = " def as_interface(self, interface_class): + return interface_class.from_value(self._obj) +"; + pub fn generate_runtime_support_module() -> String { format!( "{HEADER}{FUTURE_ANNOTATIONS}{RUNTIME_SUPPORT_BODY}{}{}", diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs index 4eb5dd40..e70c2dfc 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs @@ -351,8 +351,7 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe iface.name )); out.push('\n'); - out.push_str(" def as_interface(self, interface_class):\n"); - out.push_str(" return interface_class.from_value(self._obj)\n"); + out.push_str(AS_INTERFACE_METHOD); out.push('\n'); } From 23bd5ceeaffc204ca55ffde4bec6ac8d0d71da4b Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 12:01:55 +0800 Subject: [PATCH 02/12] Explain released WinRT values in the Python binding A projected object used after its projected_lifetime_scope() exited, or after release_projected(), failed with "RuntimeError: invoke() requires an Object value". release() replaced the payload with WinRTValue::Null, so a released value was indistinguishable from a WinRT null, and each entry point validated its receiver with its own match and message. DynWinRTValue now records its lifecycle (Live/Released). release() is the only transition. Every native entry point validates its receiver through one classifier, which reports a released value, a WinRT null, or the actual value kind: - receiver(): Object only (invoke, invoke_all, invoke_detached, call, as_raw, identity_raw) - com_receiver(): the existing as_object() policy, which also accepts async operations (fast-path getters, call_0, call_1, activate, and composed factories) - query(): cast and delegate Invoke Released values passed as arguments to invoke, invoke_all, invoke_detached, the composed factories, call, call_1 and delegate Invoke raise the same error, naming the argument position, instead of marshalling as null. Exception types are unchanged (RuntimeError). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 12 +- bindings/py/src/delegate_method.rs | 10 +- bindings/py/src/errors.rs | 23 ++ bindings/py/src/runtime.rs | 254 +++++++++++++---------- bindings/py/tests/test_error_messages.py | 227 ++++++++++++++++++++ tests/e2e/e2e_specs.json | 10 + tests/e2e/e2e_specs.schema.json | 1 + tests/e2e/runners/py_runner.py | 30 +++ 8 files changed, 448 insertions(+), 119 deletions(-) create mode 100644 bindings/py/tests/test_error_messages.py diff --git a/bindings/py/README.md b/bindings/py/README.md index 2a1cb72c..508f89a9 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -481,11 +481,13 @@ with RoApartment(0), projected_lifetime_scope(): ``` Scopes nest in LIFO order. Wrappers that survive a closed scope remain Python -objects, but their native values are released and further WinRT calls fail. -Each scope is thread-affine: enter, use, and close it inside that thread's -`RoApartment`. Same-thread asyncio tasks inherit the active scope, while worker -threads must open their own ordered -`with RoApartment(...), projected_lifetime_scope():`. Native callbacks invoked +objects, but their native values are released: using one afterwards, as the +object of a call or as an argument, raises `RuntimeError` explaining that it +was released, as it does after `release_projected(wrapper)`. Each scope is +thread-affine: enter, use, and close it inside that thread's `RoApartment`. +Same-thread asyncio tasks inherit the active scope, while worker threads must +open their own ordered `with RoApartment(...), projected_lifetime_scope():`. +Native callbacks invoked on a foreign thread preserve other captured context but do not inherit the creator thread's lifetime scope. This includes generated delegates, raw progress handlers, and element-factory callbacks. Retained callback values remain diff --git a/bindings/py/src/delegate_method.rs b/bindings/py/src/delegate_method.rs index 50e54f80..f67c107e 100644 --- a/bindings/py/src/delegate_method.rs +++ b/bindings/py/src/delegate_method.rs @@ -5,8 +5,8 @@ use pyo3::exceptions::PyTypeError; use pyo3::prelude::*; use windows::core::{GUID, IInspectable, IUnknown, Interface}; -use crate::errors::{map_dynwinrt_error, map_windows_error}; -use crate::runtime::{DynWinRTMethodSig, DynWinRTValue, WinGUID}; +use crate::errors::map_windows_error; +use crate::runtime::{DynWinRTMethodSig, DynWinRTValue, WinGUID, native_arguments}; type DelegateCall = dyn Fn(&IUnknown, &[dynwinrt::WinRTValue]) -> windows::core::Result>; @@ -52,14 +52,14 @@ impl DynWinRTDelegateMethod { args: Vec, ) -> PyResult> { // Keep native pins, not a Python value borrow, across reentrant Invoke. - let value = value.try_borrow()?.0.clone(); - let delegate = value.cast(&self.iid).map_err(map_dynwinrt_error)?; + let value = value.try_borrow()?.clone(); + let delegate = value.query(&self.iid, "delegate Invoke()")?; let dynwinrt::WinRTValue::Object(object) = &delegate else { return Err(PyTypeError::new_err( "delegate invocation requires a managed WinRT delegate value", )); }; - let args = args.into_iter().map(|arg| arg.0).collect::>(); + let args = native_arguments("delegate Invoke()", args)?; (self.call.0)(object, &args) .map(|outputs| outputs.into_iter().map(DynWinRTValue::new).collect()) .map_err(map_windows_error) diff --git a/bindings/py/src/errors.rs b/bindings/py/src/errors.rs index f80ea216..88651404 100644 --- a/bindings/py/src/errors.rs +++ b/bindings/py/src/errors.rs @@ -8,6 +8,29 @@ use pyo3::prelude::*; #[cfg(test)] pub(crate) static UNRAISABLE_HOOK_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +const RELEASED_REASON: &str = "its projected_lifetime_scope() exited or release_projected() \ + was called. Use the object inside its scope, or don't release it."; + +/// A call on a value after `release()`, including release by its lifetime scope. +pub(crate) fn released_receiver_error() -> PyErr { + PyRuntimeError::new_err(format!( + "This WinRT object has been released: {RELEASED_REASON}" + )) +} + +/// A released value passed as argument `position` (0-based) of `operation`. +pub(crate) fn released_argument_error(operation: &str, position: usize) -> PyErr { + PyRuntimeError::new_err(format!( + "This WinRT object (argument {position} of {operation}) has been released: \ + {RELEASED_REASON}" + )) +} + +/// A live value of `kind` used where `operation` needs a WinRT object. +pub(crate) fn non_object_receiver_error(operation: &str, kind: &str) -> PyErr { + PyRuntimeError::new_err(format!("{operation} requires an Object value, got {kind}")) +} + pub(crate) fn map_windows_error(error: windows::core::Error) -> PyErr { windows_error(error, None) } diff --git a/bindings/py/src/runtime.rs b/bindings/py/src/runtime.rs index 12f201f2..aa8ffe51 100644 --- a/bindings/py/src/runtime.rs +++ b/bindings/py/src/runtime.rs @@ -12,7 +12,10 @@ use windows::Win32::System::WinRT::{ }; use windows::core::{GUID, HSTRING, IUnknown, Interface}; -use crate::errors::{map_dynwinrt_error, map_dynwinrt_error_with_context, map_windows_error}; +use crate::errors::{ + map_dynwinrt_error, map_dynwinrt_error_with_context, map_windows_error, + non_object_receiver_error, released_argument_error, released_receiver_error, +}; /// Shared MetadataTable — created once, used everywhere. static TABLE: std::sync::LazyLock> = @@ -956,11 +959,8 @@ impl DynWinRTMethodHandle { fn invoke(&self, obj: DynWinRTValue, args: Vec) -> PyResult { // Extraction retains the native object without holding a Python borrow // while an implementation callback may release the original wrapper. - let raw = match &obj.0 { - dynwinrt::WinRTValue::Object(o) => o.as_raw(), - _ => return Err(PyRuntimeError::new_err("invoke() requires an Object value")), - }; - let wrt_args: Vec = args.iter().map(|a| a.0.clone()).collect(); + let raw = obj.receiver("invoke()")?.as_raw(); + let wrt_args = native_arguments("invoke()", args)?; let results = self.0.invoke(raw, &wrt_args).map_err(map_dynwinrt_error)?; if results.is_empty() { Ok(DynWinRTValue::new(dynwinrt::WinRTValue::I32(0))) @@ -1000,18 +1000,11 @@ impl DynWinRTMethodHandle { // Owned extraction ends the Python receiver borrow before dispatch. // Move its native pin into the call so reentrant disposal can release // the original wrapper without shortening the in-flight call lifetime. - let object = match obj.0 { - dynwinrt::WinRTValue::Object(object) => object, - _ => { - return Err(PyRuntimeError::new_err( - "invoke_detached() requires an Object value", - )); - } - }; + let object = obj.into_receiver("invoke_detached()")?; let call = SameThreadCall { method: self.0.clone(), object, - args: args.into_iter().map(|arg| arg.0).collect(), + args: native_arguments("invoke_detached()", args)?, }; let results = py .detach(move || call.run()) @@ -1036,15 +1029,8 @@ impl DynWinRTMethodHandle { obj: DynWinRTValue, args: Vec, ) -> PyResult> { - let raw = match &obj.0 { - dynwinrt::WinRTValue::Object(o) => o.as_raw(), - _ => { - return Err(PyRuntimeError::new_err( - "invoke_all() requires an Object value", - )); - } - }; - let wrt_args: Vec = args.iter().map(|a| a.0.clone()).collect(); + let raw = obj.receiver("invoke_all()")?.as_raw(); + let wrt_args = native_arguments("invoke_all()", args)?; let results = self.0.invoke(raw, &wrt_args).map_err(map_dynwinrt_error)?; Ok(results.into_iter().map(DynWinRTValue::new).collect()) } @@ -1059,13 +1045,8 @@ impl DynWinRTMethodHandle { instance_output_index: usize, agile: bool, ) -> PyResult { - let factory = factory.0.as_object().ok_or_else(|| { - PyRuntimeError::new_err("invoke_composed() requires an Object factory") - })?; - let args = args - .into_iter() - .map(|argument| argument.0) - .collect::>(); + let factory = factory.com_receiver("invoke_composed() factory")?; + let args = native_arguments("invoke_composed()", args)?; dynwinrt::compose_winrt( &factory, &self.0, @@ -1102,13 +1083,8 @@ impl DynWinRTMethodHandle { agile, ); } - let factory = factory.0.as_object().ok_or_else(|| { - PyRuntimeError::new_err("invoke_composed_with_overrides() requires an Object factory") - })?; - let args = args - .into_iter() - .map(|argument| argument.0) - .collect::>(); + let factory = factory.com_receiver("invoke_composed_with_overrides() factory")?; + let args = native_arguments("invoke_composed_with_overrides()", args)?; let overrides = override_interfaces .iter() .map(|interface| interface.to_core(py)) @@ -1131,11 +1107,7 @@ impl DynWinRTMethodHandle { /// Getter → string (0 args, zero Vec allocation) fn get_string(&self, obj: DynWinRTValue) -> PyResult { - let raw = obj - .0 - .as_object() - .ok_or_else(|| PyRuntimeError::new_err("get_string: not an Object"))? - .as_raw(); + let raw = obj.com_receiver("get_string()")?.as_raw(); let hs = self .0 .call_getter_hstring(raw) @@ -1145,31 +1117,19 @@ impl DynWinRTMethodHandle { /// Getter → i32 (0 args, zero Vec allocation) fn get_i32(&self, obj: DynWinRTValue) -> PyResult { - let raw = obj - .0 - .as_object() - .ok_or_else(|| PyRuntimeError::new_err("get_i32: not an Object"))? - .as_raw(); + let raw = obj.com_receiver("get_i32()")?.as_raw(); self.0.call_getter_i32(raw).map_err(map_dynwinrt_error) } /// Getter → bool (0 args, zero Vec allocation) fn get_bool(&self, obj: DynWinRTValue) -> PyResult { - let raw = obj - .0 - .as_object() - .ok_or_else(|| PyRuntimeError::new_err("get_bool: not an Object"))? - .as_raw(); + let raw = obj.com_receiver("get_bool()")?.as_raw(); self.0.call_getter_bool(raw).map_err(map_dynwinrt_error) } /// Getter → DynWinRTValue object (0 args, zero Vec allocation) fn get_obj(&self, obj: DynWinRTValue) -> PyResult { - let raw = obj - .0 - .as_object() - .ok_or_else(|| PyRuntimeError::new_err("get_obj: not an Object"))? - .as_raw(); + let raw = obj.com_receiver("get_obj()")?.as_raw(); self.0 .call_getter_object(raw) .map(DynWinRTValue::new) @@ -1178,11 +1138,7 @@ impl DynWinRTMethodHandle { /// 1-arg invoke with hstring input → DynWinRTValue result fn invoke_hstring(&self, obj: DynWinRTValue, arg: String) -> PyResult { - let raw = obj - .0 - .as_object() - .ok_or_else(|| PyRuntimeError::new_err("invoke_hstring: not an Object"))? - .as_raw(); + let raw = obj.com_receiver("invoke_hstring()")?.as_raw(); let results = self .0 .invoke(raw, &[dynwinrt::WinRTValue::HString(HSTRING::from(arg))]) @@ -1194,11 +1150,7 @@ impl DynWinRTMethodHandle { /// 1-arg invoke with i32 input → DynWinRTValue result fn invoke_i32(&self, obj: DynWinRTValue, arg: i32) -> PyResult { - let raw = obj - .0 - .as_object() - .ok_or_else(|| PyRuntimeError::new_err("invoke_i32: not an Object"))? - .as_raw(); + let raw = obj.com_receiver("invoke_i32()")?.as_raw(); let results = self .0 .invoke(raw, &[dynwinrt::WinRTValue::I32(arg)]) @@ -1215,11 +1167,120 @@ impl DynWinRTMethodHandle { #[pyclass(from_py_object)] #[derive(Clone)] -pub struct DynWinRTValue(pub(crate) dynwinrt::WinRTValue); +pub struct DynWinRTValue(pub(crate) dynwinrt::WinRTValue, Lifecycle); + +/// Whether a value still owns its native payload. `release()` is the only +/// transition and leaves `WinRTValue::Null` behind, so this state is what +/// distinguishes a released value from a WinRT null reference. +#[derive(Clone, Copy)] +enum Lifecycle { + Live, + Released, +} impl DynWinRTValue { pub(crate) fn new(value: dynwinrt::WinRTValue) -> Self { - Self(value) + Self(value, Lifecycle::Live) + } + + /// The WinRT object receiving `operation`. + fn receiver(&self, operation: &str) -> PyResult<&IUnknown> { + match &self.0 { + dynwinrt::WinRTValue::Object(object) => Ok(object), + _ => Err(self.receiver_error(operation)), + } + } + + /// Like `receiver`, but moves the object out of this value. + fn into_receiver(self, operation: &str) -> PyResult { + match self.0 { + dynwinrt::WinRTValue::Object(object) => Ok(object), + _ => Err(self.receiver_error(operation)), + } + } + + /// The COM identity receiving `operation`. Unlike `receiver`, this also + /// accepts async operations, as the legacy convenience entry points do. + fn com_receiver(&self, operation: &str) -> PyResult { + self.0 + .as_object() + .ok_or_else(|| self.receiver_error(operation)) + } + + /// QueryInterface this value for `operation`. + pub(crate) fn query(&self, iid: &GUID, operation: &str) -> PyResult { + self.0.cast(iid).map_err(|error| match error { + dynwinrt::Error::ExpectObjectTypeError(_) => self.receiver_error(operation), + error => map_dynwinrt_error(error), + }) + } + + /// Why this value cannot receive `operation`. + fn receiver_error(&self, operation: &str) -> PyErr { + match self.ensure_live() { + Err(error) => error, + Ok(()) => non_object_receiver_error(operation, value_kind(&self.0)), + } + } + + /// Reject a released value before a native call that reports its own + /// payload errors, such as IBuffer access. + fn ensure_live(&self) -> PyResult<()> { + match self.1 { + Lifecycle::Live => Ok(()), + Lifecycle::Released => Err(released_receiver_error()), + } + } + + /// Reject this value as argument `position` of `operation` if released. + fn check_argument(&self, operation: &str, position: usize) -> PyResult<()> { + match self.1 { + Lifecycle::Live => Ok(()), + Lifecycle::Released => Err(released_argument_error(operation, position)), + } + } +} + +/// The native arguments of `operation`, rejecting released values. +pub(crate) fn native_arguments( + operation: &str, + args: Vec, +) -> PyResult> { + args.into_iter() + .enumerate() + .map(|(position, arg)| { + arg.check_argument(operation, position)?; + Ok(arg.0) + }) + .collect() +} + +fn value_kind(value: &dynwinrt::WinRTValue) -> &'static str { + use dynwinrt::WinRTValue; + match value { + WinRTValue::Bool(_) => "Bool", + WinRTValue::I8(_) => "I8", + WinRTValue::U8(_) => "U8", + WinRTValue::I16(_) => "I16", + WinRTValue::U16(_) => "U16", + WinRTValue::I32(_) => "I32", + WinRTValue::U32(_) => "U32", + WinRTValue::I64(_) => "I64", + WinRTValue::U64(_) => "U64", + WinRTValue::F32(_) => "F32", + WinRTValue::F64(_) => "F64", + WinRTValue::Object(_) => "Object", + WinRTValue::Null => "null", + WinRTValue::HString(_) => "HString", + WinRTValue::HResult(_) => "HResult", + WinRTValue::Guid(_) => "Guid", + WinRTValue::RawPtr(_) => "RawPtr", + WinRTValue::OutValue(..) => "OutValue", + WinRTValue::Async(_) => "Async", + WinRTValue::ArrayOfIUnknown(_) => "ArrayOfIUnknown", + WinRTValue::Enum { .. } => "Enum", + WinRTValue::Struct(_) => "Struct", + WinRTValue::Array(_) => "Array", } } @@ -1620,6 +1681,7 @@ impl DynWinRTValue { /// Copy the initialized bytes from a WinRT IBuffer into Python bytes. fn to_bytes<'py>(&self, py: Python<'py>) -> PyResult> { + self.ensure_live()?; dynwinrt::copy_from_ibuffer(&self.0) .map(|bytes| pyo3::types::PyBytes::new(py, &bytes)) .map_err(map_dynwinrt_error) @@ -1635,36 +1697,24 @@ impl DynWinRTValue { /// cleanup without double-releasing COM references. fn release(&mut self) { let value = std::mem::replace(&mut self.0, dynwinrt::WinRTValue::Null); + self.1 = Lifecycle::Released; drop(value); } fn as_raw(&self) -> PyResult { - match &self.0 { - dynwinrt::WinRTValue::Object(o) => Ok(o.as_raw() as i64), - _ => Err(PyRuntimeError::new_err( - "Cannot get raw pointer from non-object", - )), - } + Ok(self.receiver("as_raw()")?.as_raw() as i64) } fn identity_raw(&self) -> PyResult { - match &self.0 { - dynwinrt::WinRTValue::Object(object) => object - .cast::() - .map(|identity| identity.as_raw() as i64) - .map_err(map_windows_error), - _ => Err(PyRuntimeError::new_err( - "Cannot get COM identity from a non-object value", - )), - } + self.receiver("identity_raw()")? + .cast::() + .map(|identity| identity.as_raw() as i64) + .map_err(map_windows_error) } /// COM QueryInterface — cast to a different interface. fn cast(&self, iid: &WinGUID) -> PyResult { - self.0 - .cast(&iid.0) - .map(DynWinRTValue::new) - .map_err(map_dynwinrt_error) + self.query(&iid.0, "cast()").map(DynWinRTValue::new) } /// Invoke metadata-described Invoke on an IUnknown-rooted WinRT delegate. @@ -1683,11 +1733,7 @@ impl DynWinRTValue { let method = dynwinrt::MethodSignature::new(&*TABLE) .add_out(TABLE.object()) .build(6); - let raw = self - .0 - .as_object() - .ok_or_else(|| PyRuntimeError::new_err("activate: not an Object"))? - .as_raw(); + let raw = self.com_receiver("activate()")?.as_raw(); let result = method.call_dynamic(raw, &[]).map_err(map_windows_error)?; Ok(DynWinRTValue::new(result.into_iter().next().ok_or_else( || PyRuntimeError::new_err("activate: no result"), @@ -1701,11 +1747,7 @@ impl DynWinRTValue { let method = dynwinrt::MethodSignature::new(&*TABLE) .add_out(return_type.0.clone()) .build(method_index); - let obj_raw = self - .0 - .as_object() - .ok_or_else(|| PyRuntimeError::new_err("call_0 requires an Object value"))? - .as_raw(); + let obj_raw = self.com_receiver("call_0()")?.as_raw(); let result = method .call_dynamic(obj_raw, &[]) .map_err(map_windows_error)?; @@ -1719,16 +1761,13 @@ impl DynWinRTValue { return_type: &DynWinRTType, v1: &DynWinRTValue, ) -> PyResult { + let obj_raw = self.com_receiver("call_1()")?.as_raw(); + v1.check_argument("call_1()", 0)?; let in_type = TABLE.handle_from_kind(v1.0.get_type_kind()); let method = dynwinrt::MethodSignature::new(&*TABLE) .add_in(in_type) .add_out(return_type.0.clone()) .build(method_index); - let obj_raw = self - .0 - .as_object() - .ok_or_else(|| PyRuntimeError::new_err("call_1 requires an Object value"))? - .as_raw(); let result = method .call_dynamic(obj_raw, &[v1.0.clone()]) .map_err(map_windows_error)?; @@ -1749,10 +1788,8 @@ impl DynWinRTValue { } method = method.add_out(return_type.0.clone()); - let obj = match &self.0 { - dynwinrt::WinRTValue::Object(o) => o.as_raw(), - _ => return Err(PyRuntimeError::new_err("call() requires an Object value")), - }; + let obj = self.receiver("call()")?.as_raw(); + let winrt_args = native_arguments("call()", args)?; let mut iface = dynwinrt::InterfaceSignature::define_from_iinspectable("", Default::default(), &*TABLE); @@ -1762,7 +1799,6 @@ impl DynWinRTValue { } iface.add_method(method); - let winrt_args: Vec = args.iter().map(|a| a.0.clone()).collect(); let result = iface.methods[target_index] .call_dynamic(obj, &winrt_args) .map_err(map_windows_error)?; diff --git a/bindings/py/tests/test_error_messages.py b/bindings/py/tests/test_error_messages.py new file mode 100644 index 00000000..796a83f2 --- /dev/null +++ b/bindings/py/tests/test_error_messages.py @@ -0,0 +1,227 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Actionable errors for common runtime misuse.""" + +import re + +import pytest + +from dynwinrt import ( + DynWinRTMethodSig, + DynWinRTType, + DynWinRTValue, + DynWinRtDelegate, + RoApartment, + WinGUID, + projected_lifetime_scope, + release_projected, +) +from dynwinrt.dynwinrt import ( + _dynwinrt_cache_projected, + _dynwinrt_projected_from_native, + _dynwinrt_track_projected, +) + +IID_IURI_FACTORY = WinGUID.parse("44A9796F-723E-4FDF-A218-033E75B0C084") +IID_IURI = WinGUID.parse("9E365E57-48B2-4160-956F-C7385120BBFC") +IID_ISTRINGABLE = WinGUID.parse("96369F54-8EB6-48F0-ABCE-C1B211E627C3") +IID_TEST_DELEGATE = WinGUID.parse("5A0F1C3E-7B24-4D69-8E1F-2C3B4A5D6E7F") +RELEASED_REASON = ( + r"has been released: its projected_lifetime_scope\(\) exited or " + r"release_projected\(\) was called\. Use the object inside its scope, or " + r"don't release it\.$" +) +RELEASED = rf"^This WinRT object {RELEASED_REASON}" + +_URI_FACTORY = DynWinRTType.register_interface( + "ErrorGuidanceUriFactory", IID_IURI_FACTORY +).add_method( + "CreateUri", + DynWinRTMethodSig().add_in(DynWinRTType.hstring()).add_out(DynWinRTType.object()), +) +_URI = DynWinRTType.register_interface("ErrorGuidanceUri", IID_IURI).add_method( + "get_AbsoluteUri", DynWinRTMethodSig().add_out(DynWinRTType.hstring()) +) +_STRINGABLE = DynWinRTType.register_interface( + "ErrorGuidanceStringable", IID_ISTRINGABLE +).add_method("ToString", DynWinRTMethodSig().add_out(DynWinRTType.hstring())) + + +def released_argument(position, operation): + return ( + rf"^This WinRT object \(argument {position} of {re.escape(operation)}\) " + rf"{RELEASED_REASON}" + ) + + +class ProjectedUri: + """The generated runtime-class wrapper shape, reduced to two members.""" + + def __new__(cls, *args, **kwargs): + if len(args) == 1 and not kwargs and isinstance(args[0], DynWinRTValue): + return _dynwinrt_projected_from_native(cls, args[0], "_set_native") + return super().__new__(cls) + + def _set_native(self, obj): + self._obj = obj.cast(IID_IURI) + self._dynwinrt_native_ready = True + _dynwinrt_track_projected(self, "Windows.Foundation.Uri") + _dynwinrt_cache_projected(self) + + def __init__(self, obj): + if getattr(self, "_dynwinrt_native_ready", False): + return + self._set_native(obj) + + @classmethod + def create(cls, uri): + factory = DynWinRTValue.activation_factory("Windows.Foundation.Uri").cast( + IID_IURI_FACTORY + ) + try: + return cls( + _URI_FACTORY.method(6).invoke(factory, [DynWinRTValue.from_hstring(uri)]) + ) + finally: + factory.release() + + @property + def absolute_uri(self): + return _URI.method(6).invoke(self._obj, []).to_string() + + def to_string(self): + stringable = self._obj.cast(IID_ISTRINGABLE) + return _STRINGABLE.method(6).invoke(stringable, []).to_string() + + +def _assert_released(uri): + with pytest.raises(RuntimeError, match=RELEASED): + uri.absolute_uri + with pytest.raises(RuntimeError, match=RELEASED): + uri.to_string() + assert uri._obj.is_null() + + +def _released_uri_value(): + value = DynWinRTValue.activation_factory("Windows.Foundation.Uri") + value.release() + value.release() + assert value.is_null() + return value + + +def test_projection_used_after_scope_exit_explains_the_release(): + with RoApartment(): + with projected_lifetime_scope(): + uri = ProjectedUri.create("https://example.com/scoped") + assert uri.absolute_uri == "https://example.com/scoped" + + _assert_released(uri) + + +def test_projection_used_after_release_projected_explains_the_release(): + with RoApartment(): + uri = ProjectedUri.create("https://example.com/released") + assert uri.to_string() == "https://example.com/released" + release_projected(uri) + + _assert_released(uri) + + +def test_receivers_report_released_values_null_and_value_kinds(): + method = _URI.method(6) + receivers = ( + ("invoke()", lambda value: method.invoke(value, [])), + ("invoke_all()", lambda value: method.invoke_all(value, [])), + ("invoke_detached()", lambda value: method.invoke_detached(value, [])), + ("get_string()", lambda value: method.get_string(value)), + ("call_0()", lambda value: value.call_0(6, DynWinRTType.hstring())), + ("call()", lambda value: value.call(6, DynWinRTType.hstring(), [], [])), + ("as_raw()", lambda value: value.as_raw()), + ("identity_raw()", lambda value: value.identity_raw()), + ("cast()", lambda value: value.cast(IID_IURI)), + ) + for value, kind in ( + (DynWinRTValue.from_i32(7), "I32"), + (DynWinRTValue.from_hstring("text"), "HString"), + (DynWinRTValue.null_value(), "null"), + ): + for operation, call in receivers: + expected = rf"^{re.escape(operation)} requires an Object value, got {kind}$" + with pytest.raises(RuntimeError, match=expected): + call(value) + + with RoApartment(): + released = _released_uri_value() + for _, call in receivers: + with pytest.raises(RuntimeError, match=RELEASED): + call(released) + + buffer = DynWinRTValue.from_bytes(b"released") + buffer.release() + with pytest.raises(RuntimeError, match=RELEASED): + buffer.to_bytes() + + +def test_released_arguments_are_rejected_by_position(): + with RoApartment(): + uri = ProjectedUri.create("https://example.com/argument") + other = ProjectedUri.create("https://example.com/argument") + released = _released_uri_value() + method = _URI.method(6) + live = DynWinRTValue.from_i32(1) + # IUriRuntimeClass.Equals(Uri) is vtable slot 21. + equals = (21, DynWinRTType.bool_type(), [DynWinRTType.object()]) + + assert uri._obj.call(*equals, [other._obj]).to_bool() + for operation, call in ( + ("invoke()", lambda: method.invoke(uri._obj, [live, released])), + ("invoke_all()", lambda: method.invoke_all(uri._obj, [live, released])), + ( + "invoke_detached()", + lambda: method.invoke_detached(uri._obj, [live, released]), + ), + ("call()", lambda: uri._obj.call(*equals, [live, released])), + ): + with pytest.raises(RuntimeError, match=released_argument(1, operation)): + call() + with pytest.raises(RuntimeError, match=released_argument(0, "call_1()")): + uri._obj.call_1(21, DynWinRTType.bool_type(), released) + + release_projected(uri) + release_projected(other) + + +def test_delegate_invocation_rejects_released_receivers_and_arguments(): + signature = DynWinRTMethodSig().add_in(DynWinRTType.object()) + calls = [] + delegate = DynWinRtDelegate.create( + IID_TEST_DELEGATE, + [DynWinRTType.object()], + lambda argument: calls.append(argument.is_null()), + ).to_value() + try: + assert delegate.invoke_delegate( + IID_TEST_DELEGATE, signature, [DynWinRTValue.null_value()] + ) == [] + assert calls == [True] + + with RoApartment(): + released = _released_uri_value() + with pytest.raises( + RuntimeError, match=released_argument(0, "delegate Invoke()") + ): + delegate.invoke_delegate(IID_TEST_DELEGATE, signature, [released]) + assert calls == [True] + finally: + delegate.release() + + with pytest.raises(RuntimeError, match=RELEASED): + delegate.invoke_delegate( + IID_TEST_DELEGATE, signature, [DynWinRTValue.null_value()] + ) + with pytest.raises( + RuntimeError, match=r"^delegate Invoke\(\) requires an Object value, got null$" + ): + DynWinRTValue.null_value().invoke_delegate(IID_TEST_DELEGATE, signature, []) diff --git a/tests/e2e/e2e_specs.json b/tests/e2e/e2e_specs.json index 709bf04f..434b40ca 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -101,6 +101,16 @@ { "kind": "projection_identity", "member": "as_interface", "langs": ["py"], "interface_class": "IStringable" } ] }, + { + "id": "python_released_projection_error", + "namespace": "Windows.Foundation", + "class": "Uri", + "langs": ["py"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "released_projection_error", "member": "host", "args": ["https://example.com/released"] } + ] + }, { "id": "calendar_default_ctor", "namespace": "Windows.Globalization", diff --git a/tests/e2e/e2e_specs.schema.json b/tests/e2e/e2e_specs.schema.json index fc2ecf9e..2c4eb287 100644 --- a/tests/e2e/e2e_specs.schema.json +++ b/tests/e2e/e2e_specs.schema.json @@ -67,6 +67,7 @@ "nullable_object_array_roundtrip", "interface_cast", "projection_identity", + "released_projection_error", "struct_roundtrip", "array_roundtrip", "static_string_length", diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index 9bde333f..229824d6 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -341,6 +341,36 @@ async def run_check( else: cr['pass'] = True + elif kind == 'released_projection_error': + with dw.projected_lifetime_scope(): + scoped = cls(*[literal_arg(a) for a in check.get('args', [])]) + released = cls(*[literal_arg(a) for a in check.get('args', [])]) + dw.release_projected(released) + live = cls(*[literal_arg(a) for a in check.get('args', [])]) + uses = ( + ('scope exit', lambda: getattr(scoped, member)), + ('release_projected', lambda: getattr(released, member)), + ('interface cast', released.to_string), + ('argument', lambda: live.equals(released)), + ) + for label, use in uses: + try: + use() + except RuntimeError as error: + message = str(error) + if ( + 'has been released' not in message + or 'projected_lifetime_scope()' not in message + or 'release_projected()' not in message + ): + cr['error'] = f'{label}: unclear released-object error: {message}' + return cr + else: + cr['error'] = f'{label}: released projection allowed a WinRT call' + return cr + dw.release_projected(live) + cr['pass'] = True + elif kind == 'narrow_integer_overflow': cases = ( ('create_uint8', (256,)), From 01d86ef975010562aee598b0de6efd3686432bf6 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 12:05:31 +0800 Subject: [PATCH 03/12] Name the Python apartment models RoApartment(1) and ro_initialize(1) relied on magic numbers. Export dynwinrt.RO_INIT_SINGLETHREADED (0) and dynwinrt.RO_INIT_MULTITHREADED (1), sourced from the same Win32 RO_INIT_TYPE constants the apartment mapping uses, and declare them as Final in the stub. Docs and samples now use RoApartment() for the default multithreaded apartment and RoApartment(RO_INIT_SINGLETHREADED) for WinUI. Accepted values and their mapping are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 7 ++-- bindings/py/README.md | 11 ++++-- bindings/py/dynwinrt.pyi | 10 ++++- bindings/py/src/lib.rs | 5 +++ bindings/py/src/runtime.rs | 7 ++++ bindings/py/tests/test_error_messages.py | 39 +++++++++++++++++++ .../winrt-interface-implementations.md | 2 +- .../app-lifecycle-single-instance/app.py | 4 +- samples/python/app-notification/app.py | 2 +- samples/python/async-file-io/app.py | 2 +- samples/python/cryptography/app.py | 2 +- samples/python/device-watcher/app.py | 2 +- .../python/interface-implementation/app.py | 2 +- samples/python/ocr-image/app.py | 2 +- samples/python/text-to-speech/app.py | 2 +- samples/python/winui-hello-world/app.py | 3 +- .../python/winui-tic-tac-toe-code-only/app.py | 9 ++++- samples/python/winui-tic-tac-toe/app.py | 3 +- tools/dynwinrt-codegen/python/README.md | 2 +- 19 files changed, 93 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index b54a8abb..95ddfb93 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Save this as `example.py`: from dynwinrt import RoApartment, projected_lifetime_scope from generated_uri.windows.foundation import Uri -with RoApartment(1), projected_lifetime_scope(): +with RoApartment(), projected_lifetime_scope(): uri = Uri("https://example.com/path?q=1") print(uri.host) # "example.com" ``` @@ -133,8 +133,9 @@ with RoApartment(1), projected_lifetime_scope(): python .\example.py ``` -`RoApartment(1)` initializes WinRT on the current thread. The lifetime scope -releases generated wrappers before the apartment closes. +`RoApartment()` initializes WinRT on the current thread (multithreaded by +default). The lifetime scope releases generated wrappers before the apartment +closes. ## Examples diff --git a/bindings/py/README.md b/bindings/py/README.md index 508f89a9..849e8ddd 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -240,11 +240,14 @@ Use `RoApartment` to initialize COM for a thread and balance every successful initialization: ```python -with RoApartment(0): # RO_INIT_SINGLETHREADED +from dynwinrt import RO_INIT_SINGLETHREADED, RoApartment + +with RoApartment(RO_INIT_SINGLETHREADED): use_winrt() ``` -Use `RoApartment(1)` for `RO_INIT_MULTITHREADED`. Nested contexts using the same +`RoApartment()` uses `RO_INIT_MULTITHREADED`, the same as +`RoApartment(RO_INIT_MULTITHREADED)`. Nested contexts using the same model are supported. Requesting a conflicting model raises `OSError` with `RPC_E_CHANGED_MODE`. The low-level `ro_initialize()` API remains available, but each successful call, including `S_FALSE`, must be paired with one @@ -473,9 +476,9 @@ Use a projection lifetime scope inside the COM apartment so wrappers release their native values before `RoUninitialize`: ```python -from dynwinrt import RoApartment, projected_lifetime_scope +from dynwinrt import RO_INIT_SINGLETHREADED, RoApartment, projected_lifetime_scope -with RoApartment(0), projected_lifetime_scope(): +with RoApartment(RO_INIT_SINGLETHREADED), projected_lifetime_scope(): app = Application.create() # Create and use WinUI objects here. ``` diff --git a/bindings/py/dynwinrt.pyi b/bindings/py/dynwinrt.pyi index 9292a1a5..b82d58fa 100644 --- a/bindings/py/dynwinrt.pyi +++ b/bindings/py/dynwinrt.pyi @@ -1,5 +1,5 @@ from collections.abc import Coroutine -from typing import Any, Awaitable, Callable, Generic, List, Literal, Mapping, Optional, Protocol, Sequence, TypeVar, Union, final, overload +from typing import Any, Awaitable, Callable, Final, Generic, List, Literal, Mapping, Optional, Protocol, Sequence, TypeVar, Union, final, overload from uuid import UUID _T = TypeVar("_T", covariant=True) @@ -24,6 +24,8 @@ class _DynWinRTRuntimeClass(_DynWinRTProjectableClass): ... __all__ = [ "WinAppSDKContext", "RoApartment", + "RO_INIT_SINGLETHREADED", + "RO_INIT_MULTITHREADED", "WinGUID", "DynWinRTType", "DynWinRTMethodSig", @@ -64,6 +66,12 @@ class WinAppSDKContext: def resource_pri_path(self) -> str: ... +# apartment_type values for RoApartment(...) and ro_initialize(...); +# RoApartment() uses RO_INIT_MULTITHREADED. +RO_INIT_SINGLETHREADED: Final = 0 +RO_INIT_MULTITHREADED: Final = 1 + + @final class RoApartment: def __new__( diff --git a/bindings/py/src/lib.rs b/bindings/py/src/lib.rs index f5bf9fcf..3113ceb9 100644 --- a/bindings/py/src/lib.rs +++ b/bindings/py/src/lib.rs @@ -581,6 +581,11 @@ _Coroutine.register(_DynWinRTAsyncWithProgress) None, )?; + // Constants + for (name, apartment_type) in super::runtime::APARTMENT_TYPE_CONSTANTS { + m.add(name, apartment_type.0)?; + } + // Functions m.add_function(wrap_pyfunction!(super::runtime::init_winappsdk, m)?)?; m.add_function(wrap_pyfunction!(super::runtime::ro_initialize, m)?)?; diff --git a/bindings/py/src/runtime.rs b/bindings/py/src/runtime.rs index aa8ffe51..6fb00b7f 100644 --- a/bindings/py/src/runtime.rs +++ b/bindings/py/src/runtime.rs @@ -137,6 +137,13 @@ pub struct RoApartment { /// `apartment_type` used when Python omits it: the multithreaded apartment. const DEFAULT_APARTMENT_TYPE: i32 = RO_INIT_MULTITHREADED.0; +/// Module constants naming the `apartment_type` values Python passes to +/// `RoApartment(...)` and `ro_initialize(...)`. +pub(crate) const APARTMENT_TYPE_CONSTANTS: [(&str, RO_INIT_TYPE); 2] = [ + ("RO_INIT_SINGLETHREADED", RO_INIT_SINGLETHREADED), + ("RO_INIT_MULTITHREADED", RO_INIT_MULTITHREADED), +]; + /// The `RoInitialize` model for a Python `apartment_type`. Values other than /// `RO_INIT_SINGLETHREADED` keep their historical multithreaded meaning. fn ro_init_type(apartment_type: i32) -> RO_INIT_TYPE { diff --git a/bindings/py/tests/test_error_messages.py b/bindings/py/tests/test_error_messages.py index 796a83f2..4aedcee2 100644 --- a/bindings/py/tests/test_error_messages.py +++ b/bindings/py/tests/test_error_messages.py @@ -4,10 +4,14 @@ """Actionable errors for common runtime misuse.""" import re +import threading import pytest +import dynwinrt from dynwinrt import ( + RO_INIT_MULTITHREADED, + RO_INIT_SINGLETHREADED, DynWinRTMethodSig, DynWinRTType, DynWinRTValue, @@ -16,6 +20,8 @@ WinGUID, projected_lifetime_scope, release_projected, + ro_initialize, + ro_uninitialize, ) from dynwinrt.dynwinrt import ( _dynwinrt_cache_projected, @@ -27,6 +33,7 @@ IID_IURI = WinGUID.parse("9E365E57-48B2-4160-956F-C7385120BBFC") IID_ISTRINGABLE = WinGUID.parse("96369F54-8EB6-48F0-ABCE-C1B211E627C3") IID_TEST_DELEGATE = WinGUID.parse("5A0F1C3E-7B24-4D69-8E1F-2C3B4A5D6E7F") +RPC_E_CHANGED_MODE = -2147417850 RELEASED_REASON = ( r"has been released: its projected_lifetime_scope\(\) exited or " r"release_projected\(\) was called\. Use the object inside its scope, or " @@ -225,3 +232,35 @@ def test_delegate_invocation_rejects_released_receivers_and_arguments(): RuntimeError, match=r"^delegate Invoke\(\) requires an Object value, got null$" ): DynWinRTValue.null_value().invoke_delegate(IID_TEST_DELEGATE, signature, []) + + +def test_apartment_constants_name_the_ro_init_models(): + assert (RO_INIT_SINGLETHREADED, RO_INIT_MULTITHREADED) == (0, 1) + assert {"RO_INIT_SINGLETHREADED", "RO_INIT_MULTITHREADED"} <= set(dynwinrt.__all__) + assert repr(RoApartment()) == repr(RoApartment(RO_INIT_MULTITHREADED)) + observed = [] + errors = [] + + def worker(): + # Keep no traceback cycles: unsendable apartments must drop on this thread. + try: + ro_initialize(RO_INIT_MULTITHREADED) + ro_uninitialize() + with RoApartment(RO_INIT_SINGLETHREADED) as apartment: + observed.append(repr(apartment)) + try: + with RoApartment(RO_INIT_MULTITHREADED): + pass + except OSError as error: + observed.append(error.winerror) + except BaseException as error: + errors.append(repr(error)) + + thread = threading.Thread(target=worker) + thread.start() + thread.join() + assert not errors + assert observed == [ + "RoApartment(apartment_type=0, active=true)", + RPC_E_CHANGED_MODE, + ] diff --git a/docs/guides/windows/winrt-interface-implementations.md b/docs/guides/windows/winrt-interface-implementations.md index 433b881d..7d5a4da4 100644 --- a/docs/guides/windows/winrt-interface-implementations.md +++ b/docs/guides/windows/winrt-interface-implementations.md @@ -71,7 +71,7 @@ class TextHandler: def to_string(self) -> str: return "Implemented in Python" -with RoApartment(1), IStringable.implement(TextHandler()) as impl: +with RoApartment(), IStringable.implement(TextHandler()) as impl: print(impl.value.to_string()) # Crosses the native vtable in both directions. ``` diff --git a/samples/python/app-lifecycle-single-instance/app.py b/samples/python/app-lifecycle-single-instance/app.py index 0d655305..4c5f0ea5 100644 --- a/samples/python/app-lifecycle-single-instance/app.py +++ b/samples/python/app-lifecycle-single-instance/app.py @@ -27,7 +27,7 @@ async def run_primary( ) -> None: runtime = init_winappsdk(major, minor) try: - with RoApartment(1), projected_lifetime_scope(): + with RoApartment(), projected_lifetime_scope(): instance = AppInstance.find_or_register_for_key(key) if instance is None or not instance.is_current: raise RuntimeError("Could not register the primary instance") @@ -64,7 +64,7 @@ def on_activated( async def redirect_to_primary(key: str, major: int, minor: int) -> None: runtime = init_winappsdk(major, minor) try: - with RoApartment(1), projected_lifetime_scope(): + with RoApartment(), projected_lifetime_scope(): current = AppInstance.get_current() target = AppInstance.find_or_register_for_key(key) if current is None or target is None: diff --git a/samples/python/app-notification/app.py b/samples/python/app-notification/app.py index d552f08b..693de3ae 100644 --- a/samples/python/app-notification/app.py +++ b/samples/python/app-notification/app.py @@ -42,7 +42,7 @@ async def run( ) -> None: runtime = init_winappsdk(major, minor) try: - with RoApartment(1), projected_lifetime_scope(): + with RoApartment(), projected_lifetime_scope(): supported = AppNotificationManager.is_supported() notification = build_notification() if smoke: diff --git a/samples/python/async-file-io/app.py b/samples/python/async-file-io/app.py index 6cd7111d..56ed5804 100644 --- a/samples/python/async-file-io/app.py +++ b/samples/python/async-file-io/app.py @@ -13,7 +13,7 @@ async def run() -> None: expected = "Hello from dynwinrt.\nAsync WinRT file I/O works." with tempfile.TemporaryDirectory(prefix="dynwinrt-python-") as directory: - with RoApartment(1), projected_lifetime_scope(): + with RoApartment(), projected_lifetime_scope(): folder = await StorageFolder.get_folder_from_path_async(directory) if folder is None: raise RuntimeError("StorageFolder returned no temporary folder") diff --git a/samples/python/cryptography/app.py b/samples/python/cryptography/app.py index e0a7c5c3..2ff36fee 100644 --- a/samples/python/cryptography/app.py +++ b/samples/python/cryptography/app.py @@ -7,7 +7,7 @@ def sha256(text: str) -> str: - with RoApartment(1), projected_lifetime_scope(): + with RoApartment(), projected_lifetime_scope(): provider = HashAlgorithmProvider.open_algorithm("SHA256") if provider is None: raise RuntimeError("SHA256 provider is unavailable") diff --git a/samples/python/device-watcher/app.py b/samples/python/device-watcher/app.py index 83f5578e..a1576d72 100644 --- a/samples/python/device-watcher/app.py +++ b/samples/python/device-watcher/app.py @@ -13,7 +13,7 @@ async def enumerate_devices(timeout: int, show_names: bool) -> None: - with RoApartment(1), projected_lifetime_scope(): + with RoApartment(), projected_lifetime_scope(): watcher = DeviceInformation.create_watcher() if watcher is None: raise RuntimeError("DeviceInformation returned no watcher") diff --git a/samples/python/interface-implementation/app.py b/samples/python/interface-implementation/app.py index 92234cdc..35da8586 100644 --- a/samples/python/interface-implementation/app.py +++ b/samples/python/interface-implementation/app.py @@ -65,7 +65,7 @@ def close(self) -> None: def main() -> None: - with RoApartment(1), projected_lifetime_scope(): + with RoApartment(), projected_lifetime_scope(): handlers = Task() with IBackgroundTaskInstance.implement(TaskInstance()) as instance_impl, IBackgroundTask.implement( handlers, interfaces=[(IStringable, handlers), (IClosable, handlers)] diff --git a/samples/python/ocr-image/app.py b/samples/python/ocr-image/app.py index 6cd4c9cc..90953ef8 100644 --- a/samples/python/ocr-image/app.py +++ b/samples/python/ocr-image/app.py @@ -15,7 +15,7 @@ def normalized_words(value: str) -> set[str]: async def recognize(path: Path) -> str: - with RoApartment(1), projected_lifetime_scope(): + with RoApartment(), projected_lifetime_scope(): file = await StorageFile.get_file_from_path_async(str(path.resolve())) if file is None: raise RuntimeError("StorageFile returned no image file") diff --git a/samples/python/text-to-speech/app.py b/samples/python/text-to-speech/app.py index c3267524..f6991911 100644 --- a/samples/python/text-to-speech/app.py +++ b/samples/python/text-to-speech/app.py @@ -10,7 +10,7 @@ async def speak(text: str, smoke: bool) -> None: - with RoApartment(1), projected_lifetime_scope(): + with RoApartment(), projected_lifetime_scope(): with SpeechSynthesizer() as synthesizer: stream = await synthesizer.synthesize_text_to_stream_async(text) if stream is None: diff --git a/samples/python/winui-hello-world/app.py b/samples/python/winui-hello-world/app.py index f3596534..9d1a2903 100644 --- a/samples/python/winui-hello-world/app.py +++ b/samples/python/winui-hello-world/app.py @@ -9,6 +9,7 @@ ) from dynwinrt import ( + RO_INIT_SINGLETHREADED, RoApartment, init_winappsdk, project_as, @@ -48,7 +49,7 @@ def run(smoke: bool, major: int, minor: int) -> None: subscriptions: list[Callable[[], None]] = [] try: - with RoApartment(0), projected_lifetime_scope(): + with RoApartment(RO_INIT_SINGLETHREADED), projected_lifetime_scope(): def initialize(_params: object) -> None: def launched() -> None: diff --git a/samples/python/winui-tic-tac-toe-code-only/app.py b/samples/python/winui-tic-tac-toe-code-only/app.py index 8c0cd9f9..6ba72213 100644 --- a/samples/python/winui-tic-tac-toe-code-only/app.py +++ b/samples/python/winui-tic-tac-toe-code-only/app.py @@ -6,7 +6,12 @@ ROOT / ".runtime" / "Microsoft.WindowsAppRuntime.Bootstrap.dll" ) -from dynwinrt import RoApartment, init_winappsdk, projected_lifetime_scope +from dynwinrt import ( + RO_INIT_SINGLETHREADED, + RoApartment, + init_winappsdk, + projected_lifetime_scope, +) from generated.microsoft.ui.xaml import ( Application, ApplicationTheme, @@ -61,7 +66,7 @@ def run() -> None: state: dict[str, object] = {} try: - with RoApartment(0), projected_lifetime_scope(): + with RoApartment(RO_INIT_SINGLETHREADED), projected_lifetime_scope(): def initialize(_params: object) -> None: def launched() -> None: diff --git a/samples/python/winui-tic-tac-toe/app.py b/samples/python/winui-tic-tac-toe/app.py index fb92b7f2..dd9e1eee 100644 --- a/samples/python/winui-tic-tac-toe/app.py +++ b/samples/python/winui-tic-tac-toe/app.py @@ -8,6 +8,7 @@ ) from dynwinrt import ( + RO_INIT_SINGLETHREADED, RoApartment, init_winappsdk, project_as, @@ -66,7 +67,7 @@ def run() -> None: state: dict[str, object] = {} try: - with RoApartment(0), projected_lifetime_scope(): + with RoApartment(RO_INIT_SINGLETHREADED), projected_lifetime_scope(): registration = StackPanel.register_xaml_runtime_class( "DynWinRT.Example.TicTacToePanel", TicTacToePanel, diff --git a/tools/dynwinrt-codegen/python/README.md b/tools/dynwinrt-codegen/python/README.md index 2b16f617..f9dda50b 100644 --- a/tools/dynwinrt-codegen/python/README.md +++ b/tools/dynwinrt-codegen/python/README.md @@ -47,7 +47,7 @@ The generated package can then be imported normally: from dynwinrt import RoApartment, projected_lifetime_scope from generated_uri.windows.foundation import Uri -with RoApartment(1), projected_lifetime_scope(): +with RoApartment(), projected_lifetime_scope(): uri = Uri("https://example.com/path") print(uri.host) ``` From 8c7afb82922ec32dfef0883aa839de6173ae1764 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 12:07:34 +0800 Subject: [PATCH 04/12] Hint how to initialize WinRT on CO_E_NOTINITIALIZED Calling WinRT on a thread without an apartment surfaced only the localized CoInitialize text. windows_error() now consults one HRESULT_HINTS table and appends its guidance to the Windows description. Its first entry covers CO_E_NOTINITIALIZED and points to `with dynwinrt.RoApartment():` or ro_initialize(RO_INIT_MULTITHREADED). OSError, winerror and errno are unchanged, and WinRT is still never initialized implicitly. The async runtime's CO_E_NOTINITIALIZED check inspects the native error before mapping, so it is unaffected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 4 ++ bindings/py/src/errors.rs | 75 +++++++++++++++++++++++- bindings/py/tests/test_error_messages.py | 68 +++++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/bindings/py/README.md b/bindings/py/README.md index 849e8ddd..44c3e501 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -253,6 +253,10 @@ model are supported. Requesting a conflicting model raises `OSError` with each successful call, including `S_FALSE`, must be paired with one `ro_uninitialize()` call on the same thread. +WinRT is never initialized implicitly. A call on a thread without an apartment +raises `OSError` with `CO_E_NOTINITIALIZED` in `error.winerror`; its message +explains how to open one. + Generated runtime classes that implement `IClosable` support `with` and an idempotent `close()` method. Prefer deterministic cleanup instead of relying on Python garbage collection. diff --git a/bindings/py/src/errors.rs b/bindings/py/src/errors.rs index 88651404..dc823075 100644 --- a/bindings/py/src/errors.rs +++ b/bindings/py/src/errors.rs @@ -4,10 +4,26 @@ use pyo3::exceptions::asyncio::CancelledError as PyCancelledError; use pyo3::exceptions::{PyIndexError, PyOSError, PyRuntimeError}; use pyo3::prelude::*; +use windows::Win32::Foundation::CO_E_NOTINITIALIZED; +use windows::core::HRESULT; #[cfg(test)] pub(crate) static UNRAISABLE_HOOK_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +/// Guidance appended to the Windows description of specific HRESULTs. +const HRESULT_HINTS: &[(HRESULT, &str)] = &[( + CO_E_NOTINITIALIZED, + "WinRT is not initialized on this thread; use `with dynwinrt.RoApartment():` \ + (or call `dynwinrt.ro_initialize(dynwinrt.RO_INIT_MULTITHREADED)`) before calling \ + WinRT APIs.", +)]; + +fn hresult_hint(code: HRESULT) -> Option<&'static str> { + HRESULT_HINTS + .iter() + .find_map(|&(hinted, hint)| (hinted == code).then_some(hint)) +} + const RELEASED_REASON: &str = "its projected_lifetime_scope() exited or release_projected() \ was called. Use the object inside its scope, or don't release it."; @@ -40,10 +56,18 @@ pub(crate) fn map_windows_error_with_context(error: windows::core::Error, contex } fn windows_error(error: windows::core::Error, context: Option<&str>) -> PyErr { - let message = match context { + let description = match context { Some(context) => format!("{context}: {}", error.message()), None => error.message(), }; + // A hint explains how to fix the failure; it never changes the error. + let message = match hresult_hint(error.code()) { + Some(hint) => match description.trim_end() { + "" => hint.to_owned(), + text => format!("{text} {hint}"), + }, + None => description, + }; // Match PyWinRT's OSError shape and preserve the signed HRESULT in winerror. PyOSError::new_err((0, message, Option::::None, error.code().0)) } @@ -70,3 +94,52 @@ pub(crate) fn map_dynwinrt_error_with_context(error: dynwinrt::Error, context: & other => PyRuntimeError::new_err(format!("{context}: {}", other.message())), } } + +#[cfg(test)] +mod tests { + use super::*; + use windows::Win32::Foundation::E_POINTER; + + fn os_error_fields(py: Python<'_>, error: PyErr) -> (i32, i32, String) { + let value = error.value(py); + assert!(value.is_instance_of::()); + ( + value.getattr("winerror").unwrap().extract().unwrap(), + value.getattr("errno").unwrap().extract().unwrap(), + value.getattr("strerror").unwrap().extract().unwrap(), + ) + } + + #[test] + fn hinted_hresults_keep_their_os_error_and_append_guidance() { + Python::initialize(); + Python::attach(|py| { + let hint = hresult_hint(CO_E_NOTINITIALIZED).expect("CO_E_NOTINITIALIZED hint"); + let not_initialized = windows::core::Error::from_hresult(CO_E_NOTINITIALIZED); + let (winerror, errno, message) = + os_error_fields(py, map_windows_error(not_initialized.clone())); + assert_eq!(winerror, CO_E_NOTINITIALIZED.0); + assert_eq!(errno, 22); + assert!(message.ends_with(hint), "{message}"); + assert_ne!(message, hint, "the Windows description must remain"); + + let (_, _, message) = os_error_fields( + py, + map_dynwinrt_error_with_context( + dynwinrt::Error::WindowsError(not_initialized), + "activation failed", + ), + ); + assert!(message.starts_with("activation failed: "), "{message}"); + assert!(message.ends_with(hint), "{message}"); + + assert_eq!(hresult_hint(E_POINTER), None); + let (winerror, _, message) = os_error_fields( + py, + map_dynwinrt_error(dynwinrt::Error::WindowsError(E_POINTER.into())), + ); + assert_eq!(winerror, E_POINTER.0); + assert!(!message.contains("RoApartment"), "{message}"); + }); + } +} diff --git a/bindings/py/tests/test_error_messages.py b/bindings/py/tests/test_error_messages.py index 4aedcee2..e91b8af8 100644 --- a/bindings/py/tests/test_error_messages.py +++ b/bindings/py/tests/test_error_messages.py @@ -3,7 +3,12 @@ """Actionable errors for common runtime misuse.""" +import errno +import json import re +import subprocess +import sys +import textwrap import threading import pytest @@ -34,6 +39,12 @@ IID_ISTRINGABLE = WinGUID.parse("96369F54-8EB6-48F0-ABCE-C1B211E627C3") IID_TEST_DELEGATE = WinGUID.parse("5A0F1C3E-7B24-4D69-8E1F-2C3B4A5D6E7F") RPC_E_CHANGED_MODE = -2147417850 +CO_E_NOTINITIALIZED = -2147221008 +NOT_INITIALIZED_HINT = ( + "WinRT is not initialized on this thread; use `with dynwinrt.RoApartment():` " + "(or call `dynwinrt.ro_initialize(dynwinrt.RO_INIT_MULTITHREADED)`) before " + "calling WinRT APIs." +) RELEASED_REASON = ( r"has been released: its projected_lifetime_scope\(\) exited or " r"release_projected\(\) was called\. Use the object inside its scope, or " @@ -264,3 +275,60 @@ def worker(): "RoApartment(apartment_type=0, active=true)", RPC_E_CHANGED_MODE, ] + + +def test_uninitialized_thread_error_explains_apartment_setup(): + # Test modules initialize the process MTA at import, which makes every new + # thread an implicit MTA member. Observe a genuinely uninitialized thread + # in a fresh interpreter instead. + script = r''' + import json + import threading + from dynwinrt import DynWinRTValue + + caught = [] + + def call_without_apartment(): + try: + DynWinRTValue.activation_factory("Windows.Foundation.Uri") + except BaseException as error: + caught.append(error) + + thread = threading.Thread(target=call_without_apartment) + thread.start() + thread.join() + error = caught[0] + print(json.dumps({ + "type": type(error).__name__, + "winerror": getattr(error, "winerror", None), + "errno": getattr(error, "errno", None), + "strerror": getattr(error, "strerror", None), + "message": str(error), + })) + ''' + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(script)], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert result.returncode == 0, result.stderr + report = json.loads(result.stdout) + + assert report["type"] == "OSError" + assert report["winerror"] == CO_E_NOTINITIALIZED + assert report["errno"] == errno.EINVAL + strerror = report["strerror"] + assert report["message"] == f"[WinError {CO_E_NOTINITIALIZED}] {strerror}" + assert strerror.endswith(f" {NOT_INITIALIZED_HINT}"), strerror + assert strerror[: -len(NOT_INITIALIZED_HINT)].strip(), "Windows text must remain" + + +def test_other_hresults_do_not_mention_apartment_setup(): + with RoApartment(): + with pytest.raises(OSError) as exc_info: + DynWinRTValue.activation_factory("Contoso.DynWinRT.MissingClass") + + assert exc_info.value.winerror != CO_E_NOTINITIALIZED + assert "RoApartment" not in str(exc_info.value) From 2f7064c1cb51b2e8900f01d03668d3c6dcb0a02f Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 12:21:50 +0800 Subject: [PATCH 05/12] Explain as_interface() misuse in generated Python obj.as_interface(StorageFile) failed with "AttributeError: type object 'StorageFile' has no attribute 'from_value'" because each generated as_interface() duck-typed on from_value. Every generated as_interface() now delegates to one codegen-owned _runtime.py helper, _dynwinrt_as_interface. It validates the declared _DynWinRTProjector contract (from_value) and raises TypeError. For generated runtime classes, the message points to dynwinrt.project_as(obj, StorageFile). Valid targets behave exactly as before, and stubs are unchanged. The helper is imported through the shared import list, so the seven Python snapshots change in their import header and as_interface() bodies. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 5 +- tests/e2e/e2e_specs.json | 3 +- tests/e2e/e2e_specs.schema.json | 1 + tests/e2e/runners/py_runner.py | 12 +++ .../src/codegen/winrt/python/generator/mod.rs | 19 +++- .../tests/python_as_interface_test.rs | 91 +++++++++++++++++++ .../snapshots/data_writer_py/data_writer.py | 6 +- ...i_iterator_i_www_form_url_decoder_entry.py | 4 +- .../tests/snapshots/uri_py/i_stringable.py | 4 +- ...ntime_class_with_absolute_canonical_uri.py | 4 +- .../uri_py/i_www_form_url_decoder_entry.py | 4 +- .../tests/snapshots/uri_py/uri.py | 8 +- .../snapshots/uri_py/www_form_url_decoder.py | 8 +- 13 files changed, 145 insertions(+), 24 deletions(-) create mode 100644 tools/dynwinrt-codegen/tests/python_as_interface_test.rs diff --git a/bindings/py/README.md b/bindings/py/README.md index 44c3e501..735e41ae 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -231,8 +231,9 @@ inspectable, and other types) and native getter failures raise an exception. Use `wrapper.as_interface(InterfaceClass)` when converting an existing wrapper to an interface view. Use `InterfaceClass.from_value(raw)` for a raw -`DynWinRTValue`. Do not call the internal `_from_native()` method from -application code. +`DynWinRTValue`. `as_interface()` accepts generated interface classes only; +passing a runtime class raises `TypeError` that points to `project_as()`. Do not +call the internal `_from_native()` method from application code. ## COM apartments and cleanup diff --git a/tests/e2e/e2e_specs.json b/tests/e2e/e2e_specs.json index 434b40ca..5a28ff8f 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -98,7 +98,8 @@ }, "checks": [ { "kind": "interface_cast", "member": "as_interface", "interface_module": "uri", "interface_class": "IStringable", "method": "to_string", "contains": "example.com" }, - { "kind": "projection_identity", "member": "as_interface", "langs": ["py"], "interface_class": "IStringable" } + { "kind": "projection_identity", "member": "as_interface", "langs": ["py"], "interface_class": "IStringable" }, + { "kind": "as_interface_rejects_runtime_class", "member": "as_interface", "langs": ["py"] } ] }, { diff --git a/tests/e2e/e2e_specs.schema.json b/tests/e2e/e2e_specs.schema.json index 2c4eb287..743f54f9 100644 --- a/tests/e2e/e2e_specs.schema.json +++ b/tests/e2e/e2e_specs.schema.json @@ -67,6 +67,7 @@ "nullable_object_array_roundtrip", "interface_cast", "projection_identity", + "as_interface_rejects_runtime_class", "released_projection_error", "struct_roundtrip", "array_roundtrip", diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index 229824d6..bd4a8a53 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -341,6 +341,18 @@ async def run_check( else: cr['pass'] = True + elif kind == 'as_interface_rejects_runtime_class': + try: + obj.as_interface(cls) + except TypeError as error: + expected = f'dynwinrt.project_as(obj, {cls.__name__})' + if expected not in str(error): + cr['error'] = f'TypeError did not suggest {expected}: {error}' + else: + cr['pass'] = True + else: + cr['error'] = 'as_interface() accepted a runtime class' + elif kind == 'released_projection_error': with dw.projected_lifetime_scope(): scoped = cls(*[literal_arg(a) for a in check.get('args', [])]) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs index 7bf2f758..55c013b8 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -53,7 +53,7 @@ from ._runtime import ( _dynwinrt_datetime_to_ticks, _dynwinrt_delegate, _dynwinrt_enum, _dynwinrt_guid, _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, _dynwinrt_ticks_to_timedelta, _dynwinrt_timedelta_to_ticks, - _dynwinrt_cache_projected, _dynwinrt_projected_from_native, + _dynwinrt_as_interface, _dynwinrt_cache_projected, _dynwinrt_projected_from_native, _dynwinrt_symbol, _dynwinrt_track_projected, _dynwinrt_uuid, _dynwinrt_vector, _dynwinrt_wrap_values, ) @@ -128,11 +128,26 @@ def _dynwinrt_can_cast(value, iid): return False projected.release() return True + + +def _dynwinrt_as_interface(native, interface_class): + from_value = getattr(interface_class, 'from_value', None) + if from_value is not None: + return from_value(native) + name = getattr(interface_class, '__name__', type(interface_class).__name__) + if getattr(interface_class, '_dynwinrt_runtime_class_type', False) or getattr( + interface_class, '_dynwinrt_projectable_class_type', False + ): + raise TypeError( + f'as_interface() requires a generated interface class, but {name} is a ' + f'runtime class. Use dynwinrt.project_as(obj, {name}) to cast to a runtime class.' + ) + raise TypeError(f'as_interface() requires a generated interface class, not {name}.') \n"; /// `as_interface()`, emitted for every generated class and interface view. const AS_INTERFACE_METHOD: &str = " def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) "; pub fn generate_runtime_support_module() -> String { diff --git a/tools/dynwinrt-codegen/tests/python_as_interface_test.rs b/tools/dynwinrt-codegen/tests/python_as_interface_test.rs new file mode 100644 index 00000000..d67bd7f1 --- /dev/null +++ b/tools/dynwinrt-codegen/tests/python_as_interface_test.rs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod common; + +use std::collections::HashSet; + +use dynwinrt_codegen::codegen::python::generate_runtime_support_module; +use dynwinrt_codegen::meta::{ClassMeta, InterfaceMeta}; + +const AS_INTERFACE: &str = " def as_interface(self, interface_class): + return _dynwinrt_as_interface(self._obj, interface_class) +"; + +fn interface(name: &str, iid: &str) -> InterfaceMeta { + InterfaceMeta { + name: name.into(), + namespace: "Contoso".into(), + iid: iid.into(), + ..Default::default() + } +} + +fn assert_imports_helper(module: &str) { + let (_, imports) = module + .split_once("from ._runtime import (") + .expect("runtime support import"); + let (imports, _) = imports.split_once(')').expect("closed import list"); + assert!( + imports + .split(',') + .any(|name| name.trim() == "_dynwinrt_as_interface"), + "{module}" + ); +} + +#[test] +fn every_as_interface_delegates_to_the_runtime_support_helper() { + let class = ClassMeta { + name: "Widget".into(), + namespace: "Contoso".into(), + full_name: "Contoso.Widget".into(), + default_interface: Some(interface("IWidget", "11111111-1111-1111-1111-111111111111")), + required_interfaces: vec![interface("IExtra", "22222222-2222-2222-2222-222222222222")], + ..Default::default() + }; + let known = HashSet::from(["Widget".to_string()]); + let py = common::generate_class(&class, &known, &HashSet::new(), &HashSet::new()); + assert_imports_helper(&py); + let (runtime_class, embedded_interface) = py + .split_once("\nclass IExtra:") + .expect("embedded runtime interface"); + assert!( + runtime_class.contains("_dynwinrt_runtime_class_type = True") + && runtime_class.contains(AS_INTERFACE), + "{runtime_class}" + ); + assert!( + embedded_interface.contains(AS_INTERFACE), + "{embedded_interface}" + ); + + let py = common::generate_interface( + &interface("IWidget", "11111111-1111-1111-1111-111111111111"), + &HashSet::from(["IWidget".to_string()]), + &HashSet::new(), + ); + assert_imports_helper(&py); + assert!(py.contains(AS_INTERFACE), "{py}"); +} + +#[test] +fn as_interface_helper_rejects_runtime_classes_with_project_as_guidance() { + let runtime = generate_runtime_support_module(); + let helper = runtime + .split_once("def _dynwinrt_as_interface(native, interface_class):\n") + .map(|(_, helper)| helper.split("\n\n\n").next().unwrap_or(helper)) + .expect("as_interface helper"); + for expected in [ + "from_value = getattr(interface_class, 'from_value', None)", + "return from_value(native)", + "_dynwinrt_runtime_class_type", + "_dynwinrt_projectable_class_type", + "raise TypeError(", + "as_interface() requires a generated interface class, but {name} is a", + "Use dynwinrt.project_as(obj, {name}) to cast to a runtime class.", + "as_interface() requires a generated interface class, not {name}.", + ] { + assert!(helper.contains(expected), "missing {expected:?}:\n{helper}"); + } +} diff --git a/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py b/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py index 42b7b420..050fcba2 100644 --- a/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py +++ b/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py @@ -10,7 +10,7 @@ _dynwinrt_datetime_to_ticks, _dynwinrt_delegate, _dynwinrt_enum, _dynwinrt_guid, _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, _dynwinrt_ticks_to_timedelta, _dynwinrt_timedelta_to_ticks, - _dynwinrt_cache_projected, _dynwinrt_projected_from_native, + _dynwinrt_as_interface, _dynwinrt_cache_projected, _dynwinrt_projected_from_native, _dynwinrt_symbol, _dynwinrt_track_projected, _dynwinrt_uuid, _dynwinrt_vector, _dynwinrt_wrap_values, ) @@ -252,7 +252,7 @@ def __exit__(self, _exc_type, _exc_value, _traceback): return False def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) class IClosable: @@ -283,7 +283,7 @@ def from_value(cls, obj: DynWinRTValue) -> 'IClosable': return cls._from_native(obj.cast(IID_IClosable)) def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) def close(self) -> None: _IClosable.method(6).invoke(self._obj, []) diff --git a/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_iterator_i_www_form_url_decoder_entry.py b/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_iterator_i_www_form_url_decoder_entry.py index 2937caea..484ad085 100644 --- a/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_iterator_i_www_form_url_decoder_entry.py +++ b/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_iterator_i_www_form_url_decoder_entry.py @@ -10,7 +10,7 @@ _dynwinrt_datetime_to_ticks, _dynwinrt_delegate, _dynwinrt_enum, _dynwinrt_guid, _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, _dynwinrt_ticks_to_timedelta, _dynwinrt_timedelta_to_ticks, - _dynwinrt_cache_projected, _dynwinrt_projected_from_native, + _dynwinrt_as_interface, _dynwinrt_cache_projected, _dynwinrt_projected_from_native, _dynwinrt_symbol, _dynwinrt_track_projected, _dynwinrt_uuid, _dynwinrt_vector, _dynwinrt_wrap_values, ) @@ -66,7 +66,7 @@ def from_value(cls, obj: DynWinRTValue) -> 'IIterator_IWwwFormUrlDecoderEntry': return cls._from_native(obj.cast(IID_IIterator_IWwwFormUrlDecoderEntry)) def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) @_property diff --git a/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_stringable.py b/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_stringable.py index d1b2576a..1de16db4 100644 --- a/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_stringable.py +++ b/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_stringable.py @@ -10,7 +10,7 @@ _dynwinrt_datetime_to_ticks, _dynwinrt_delegate, _dynwinrt_enum, _dynwinrt_guid, _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, _dynwinrt_ticks_to_timedelta, _dynwinrt_timedelta_to_ticks, - _dynwinrt_cache_projected, _dynwinrt_projected_from_native, + _dynwinrt_as_interface, _dynwinrt_cache_projected, _dynwinrt_projected_from_native, _dynwinrt_symbol, _dynwinrt_track_projected, _dynwinrt_uuid, _dynwinrt_vector, _dynwinrt_wrap_values, ) @@ -113,7 +113,7 @@ def from_value(cls, obj: DynWinRTValue) -> 'IStringable': return cls._from_native(obj.cast(IID_IStringable)) def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) def to_string(self) -> str: diff --git a/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_uri_runtime_class_with_absolute_canonical_uri.py b/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_uri_runtime_class_with_absolute_canonical_uri.py index 64c0fed9..c343c075 100644 --- a/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_uri_runtime_class_with_absolute_canonical_uri.py +++ b/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_uri_runtime_class_with_absolute_canonical_uri.py @@ -10,7 +10,7 @@ _dynwinrt_datetime_to_ticks, _dynwinrt_delegate, _dynwinrt_enum, _dynwinrt_guid, _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, _dynwinrt_ticks_to_timedelta, _dynwinrt_timedelta_to_ticks, - _dynwinrt_cache_projected, _dynwinrt_projected_from_native, + _dynwinrt_as_interface, _dynwinrt_cache_projected, _dynwinrt_projected_from_native, _dynwinrt_symbol, _dynwinrt_track_projected, _dynwinrt_uuid, _dynwinrt_vector, _dynwinrt_wrap_values, ) @@ -122,7 +122,7 @@ def from_value(cls, obj: DynWinRTValue) -> 'IUriRuntimeClassWithAbsoluteCanonica return cls._from_native(obj.cast(IID_IUriRuntimeClassWithAbsoluteCanonicalUri)) def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) @_property diff --git a/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_www_form_url_decoder_entry.py b/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_www_form_url_decoder_entry.py index d255ba1d..ed4851fa 100644 --- a/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_www_form_url_decoder_entry.py +++ b/tools/dynwinrt-codegen/tests/snapshots/uri_py/i_www_form_url_decoder_entry.py @@ -10,7 +10,7 @@ _dynwinrt_datetime_to_ticks, _dynwinrt_delegate, _dynwinrt_enum, _dynwinrt_guid, _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, _dynwinrt_ticks_to_timedelta, _dynwinrt_timedelta_to_ticks, - _dynwinrt_cache_projected, _dynwinrt_projected_from_native, + _dynwinrt_as_interface, _dynwinrt_cache_projected, _dynwinrt_projected_from_native, _dynwinrt_symbol, _dynwinrt_track_projected, _dynwinrt_uuid, _dynwinrt_vector, _dynwinrt_wrap_values, ) @@ -122,7 +122,7 @@ def from_value(cls, obj: DynWinRTValue) -> 'IWwwFormUrlDecoderEntry': return cls._from_native(obj.cast(IID_IWwwFormUrlDecoderEntry)) def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) @_property diff --git a/tools/dynwinrt-codegen/tests/snapshots/uri_py/uri.py b/tools/dynwinrt-codegen/tests/snapshots/uri_py/uri.py index 70d15aa3..83ae20d3 100644 --- a/tools/dynwinrt-codegen/tests/snapshots/uri_py/uri.py +++ b/tools/dynwinrt-codegen/tests/snapshots/uri_py/uri.py @@ -10,7 +10,7 @@ _dynwinrt_datetime_to_ticks, _dynwinrt_delegate, _dynwinrt_enum, _dynwinrt_guid, _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, _dynwinrt_ticks_to_timedelta, _dynwinrt_timedelta_to_ticks, - _dynwinrt_cache_projected, _dynwinrt_projected_from_native, + _dynwinrt_as_interface, _dynwinrt_cache_projected, _dynwinrt_projected_from_native, _dynwinrt_symbol, _dynwinrt_track_projected, _dynwinrt_uuid, _dynwinrt_vector, _dynwinrt_wrap_values, ) @@ -214,7 +214,7 @@ def __repr__(self) -> str: return f'{type(self).__name__}({self.__str__()!r})' def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) class IUriRuntimeClassWithAbsoluteCanonicalUri: @@ -245,7 +245,7 @@ def from_value(cls, obj: DynWinRTValue) -> 'IUriRuntimeClassWithAbsoluteCanonica return cls._from_native(obj.cast(IID_IUriRuntimeClassWithAbsoluteCanonicalUri)) def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) @_property def absolute_canonical_uri(self) -> str: @@ -284,7 +284,7 @@ def from_value(cls, obj: DynWinRTValue) -> 'IStringable': return cls._from_native(obj.cast(IID_IStringable)) def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) def to_string(self) -> str: return _IStringable.method(6).invoke(self._obj, []).to_string() diff --git a/tools/dynwinrt-codegen/tests/snapshots/uri_py/www_form_url_decoder.py b/tools/dynwinrt-codegen/tests/snapshots/uri_py/www_form_url_decoder.py index f9baae09..1d714c6f 100644 --- a/tools/dynwinrt-codegen/tests/snapshots/uri_py/www_form_url_decoder.py +++ b/tools/dynwinrt-codegen/tests/snapshots/uri_py/www_form_url_decoder.py @@ -10,7 +10,7 @@ _dynwinrt_datetime_to_ticks, _dynwinrt_delegate, _dynwinrt_enum, _dynwinrt_guid, _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, _dynwinrt_ticks_to_timedelta, _dynwinrt_timedelta_to_ticks, - _dynwinrt_cache_projected, _dynwinrt_projected_from_native, + _dynwinrt_as_interface, _dynwinrt_cache_projected, _dynwinrt_projected_from_native, _dynwinrt_symbol, _dynwinrt_track_projected, _dynwinrt_uuid, _dynwinrt_vector, _dynwinrt_wrap_values, ) @@ -110,7 +110,7 @@ def first(self) -> Iterator[IWwwFormUrlDecoderEntry | None] | None: return (lambda value: None if value.is_null() else _dynwinrt_symbol('i_iterator_i_www_form_url_decoder_entry', 'IIterator_IWwwFormUrlDecoderEntry')(value))(_IIterable_IWwwFormUrlDecoderEntry.method(6).invoke(self._obj.cast(IID_IIterable_IWwwFormUrlDecoderEntry), [])) def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) class IVectorView_IWwwFormUrlDecoderEntry(_WinRTSequenceMixin): @@ -141,7 +141,7 @@ def from_value(cls, obj: DynWinRTValue) -> 'IVectorView_IWwwFormUrlDecoderEntry' return cls._from_native(obj.cast(IID_IVectorView_IWwwFormUrlDecoderEntry)) def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) @_property def size(self) -> int: @@ -187,7 +187,7 @@ def from_value(cls, obj: DynWinRTValue) -> 'IIterable_IWwwFormUrlDecoderEntry': return cls._from_native(obj.cast(IID_IIterable_IWwwFormUrlDecoderEntry)) def as_interface(self, interface_class): - return interface_class.from_value(self._obj) + return _dynwinrt_as_interface(self._obj, interface_class) def first(self) -> Iterator[IWwwFormUrlDecoderEntry | None] | None: return (lambda value: None if value.is_null() else _dynwinrt_symbol('i_iterator_i_www_form_url_decoder_entry', 'IIterator_IWwwFormUrlDecoderEntry')(value))(_IIterable_IWwwFormUrlDecoderEntry.method(6).invoke(self._obj, [])) From de4c81465d5f2b0bb531304bbe03cb903be4978c Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 14:20:10 +0800 Subject: [PATCH 06/12] Allocate the as_interface() helper name per module Generated modules imported _dynwinrt_as_interface unconditionally. A metadata declaration with the same module name, such as a struct named _dynwinrt_as_interface, shadowed the import, and as_interface() then called that type instead of casting. The helper is now a PythonSupportSymbol like _DynWinRTObject. The module symbol allocator yields it to metadata declarations and their allocated roles, import_line() binds it through support_symbol_import(), and every generated as_interface() calls support_symbol_reference(), for example `_dynwinrt_as_interface as _dynwinrt_as_interface_2`. Output without a collision is unchanged. A generation test builds metadata that declares the colliding struct next to Audit.Widget. It checks the aliased import and every as_interface() body in standalone and packaged output, and casts a real Windows.Foundation.Uri through the generated modules when the runtime is available. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../codegen/winrt/python/generator/class.rs | 4 +- .../src/codegen/winrt/python/generator/mod.rs | 13 +- .../codegen/winrt/python/generator/types.rs | 2 +- .../src/codegen/winrt/python/naming.rs | 60 ++- .../tests/python_as_interface_test.rs | 363 +++++++++++++++++- 5 files changed, 413 insertions(+), 29 deletions(-) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs index 8d20e9e7..f109cd00 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -763,7 +763,7 @@ pub fn generate_class( || !class.required_interfaces.is_empty() { out.push('\n'); - out.push_str(AS_INTERFACE_METHOD); + out.push_str(&as_interface_method(context)); } if winui::is_dispatcher_queue(class) { @@ -889,7 +889,7 @@ pub fn generate_class( " return cls._from_native(obj.cast(IID_{symbol}))\n" )); out.push('\n'); - out.push_str(AS_INTERFACE_METHOD); + out.push_str(&as_interface_method(context)); for methods in crate::codegen::winrt::python::overloads::grouped_methods( reorder_getters_before_setters(&req_iface.methods), ) { diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs index 55c013b8..dc996d82 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -41,6 +41,7 @@ const HEADER: &str = "# Generated by dynwinrt-codegen — do not edit\n"; const FUTURE_ANNOTATIONS: &str = "from __future__ import annotations\n"; fn import_line(context: &PythonProjectionContext) -> String { let object_input = context.support_symbol_import(PythonSupportSymbol::ObjectInput); + let as_interface = context.support_symbol_import(PythonSupportSymbol::AsInterface); format!( "\ from ._runtime import ( @@ -53,7 +54,7 @@ from ._runtime import ( _dynwinrt_datetime_to_ticks, _dynwinrt_delegate, _dynwinrt_enum, _dynwinrt_guid, _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, _dynwinrt_ticks_to_timedelta, _dynwinrt_timedelta_to_ticks, - _dynwinrt_as_interface, _dynwinrt_cache_projected, _dynwinrt_projected_from_native, + {as_interface}, _dynwinrt_cache_projected, _dynwinrt_projected_from_native, _dynwinrt_symbol, _dynwinrt_track_projected, _dynwinrt_uuid, _dynwinrt_vector, _dynwinrt_wrap_values, ) @@ -146,9 +147,13 @@ def _dynwinrt_as_interface(native, interface_class): \n"; /// `as_interface()`, emitted for every generated class and interface view. -const AS_INTERFACE_METHOD: &str = " def as_interface(self, interface_class): - return _dynwinrt_as_interface(self._obj, interface_class) -"; +/// It references the module's allocated name for the runtime-support helper. +fn as_interface_method(context: &PythonProjectionContext) -> String { + format!( + " def as_interface(self, interface_class):\n return {}(self._obj, interface_class)\n", + context.support_symbol_reference(PythonSupportSymbol::AsInterface) + ) +} pub fn generate_runtime_support_module() -> String { format!( diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs index e70c2dfc..c85f1e14 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs @@ -351,7 +351,7 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe iface.name )); out.push('\n'); - out.push_str(AS_INTERFACE_METHOD); + out.push_str(&as_interface_method(context)); out.push('\n'); } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/naming.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/naming.rs index 3dedf02b..ae2ab424 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/naming.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/naming.rs @@ -17,12 +17,14 @@ pub type PythonTypeIdentity = TypeIdentity; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) enum PythonSupportSymbol { ObjectInput, + AsInterface, } impl PythonSupportSymbol { fn name(self) -> &'static str { match self { Self::ObjectInput => "_DynWinRTObject", + Self::AsInterface => "_dynwinrt_as_interface", } } } @@ -912,7 +914,10 @@ impl PythonProjectionContext { } } // Support imports yield to metadata declarations and their allocated roles. - for helper in [PythonSupportSymbol::ObjectInput] { + for helper in [ + PythonSupportSymbol::ObjectInput, + PythonSupportSymbol::AsInterface, + ] { let preferred = helper.name(); let mut name = preferred.to_string(); let mut index = 2; @@ -1433,27 +1438,32 @@ pub fn to_snake_case_filename(name: &str) -> String { mod tests { use super::*; - #[test] - fn object_input_helper_yields_to_visible_roles_without_renaming_metadata() { - let helper = PythonSupportSymbol::ObjectInput; + fn assert_support_helper_yields_to_visible_roles(helper: PythonSupportSymbol) { + let name = helper.name(); + let [second, third] = [format!("{name}_2"), format!("{name}_3")]; + let others = [ + PythonSupportSymbol::ObjectInput, + PythonSupportSymbol::AsInterface, + ] + .into_iter() + .filter(|other| *other != helper) + .collect::>(); for kind in [ TypeIdentityKind::Class, TypeIdentityKind::Interface, TypeIdentityKind::Struct, ] { for packaged in [false, true] { - let owner = TypeIdentity::named(kind, "Audit", "_DynWinRTObject"); - let peer = - TypeIdentity::named(TypeIdentityKind::Enum, "Audit", "_DynWinRTObject_2"); - let unused = - TypeIdentity::named(TypeIdentityKind::Enum, "Unused", "_DynWinRTObject_3"); + let owner = TypeIdentity::named(kind, "Audit", name); + let peer = TypeIdentity::named(TypeIdentityKind::Enum, "Audit", &second); + let unused = TypeIdentity::named(TypeIdentityKind::Enum, "Unused", &third); let context = PythonProjectionContext::new([owner.clone(), peer.clone(), unused], packaged) .unwrap(); let structs = if kind == TypeIdentityKind::Struct { vec![TypeMeta::Struct { namespace: "Audit".into(), - name: "_DynWinRTObject".into(), + name: name.into(), fields: vec![], }] } else { @@ -1465,25 +1475,35 @@ mod tests { [(peer.clone(), PythonSymbol::Type)], [], ); - assert_eq!(module.reference_name(&owner), "_DynWinRTObject"); - assert_eq!(module.reference_name(&peer), "_DynWinRTObject_2"); - assert_eq!(module.support_symbol_reference(helper), "_DynWinRTObject_3"); + assert_eq!(module.reference_name(&owner), name); + assert_eq!(module.reference_name(&peer), second); + assert_eq!(module.support_symbol_reference(helper), third); assert_eq!( module.support_symbol_import(helper), - "_DynWinRTObject as _DynWinRTObject_3" + format!("{name} as {third}") ); + for other in &others { + assert_eq!(module.support_symbol_import(*other), other.name()); + } let isolated = context.with_local_types(Some(owner), &structs, [], []); - assert_eq!( - isolated.support_symbol_reference(helper), - "_DynWinRTObject_2" - ); + assert_eq!(isolated.support_symbol_reference(helper), second); let control = context.with_local_types(Some(peer), &[], [], []); - assert_eq!(control.support_symbol_import(helper), "_DynWinRTObject"); - assert_eq!(context.support_symbol_import(helper), "_DynWinRTObject"); + assert_eq!(control.support_symbol_import(helper), name); + assert_eq!(context.support_symbol_import(helper), name); } } } + #[test] + fn object_input_helper_yields_to_visible_roles_without_renaming_metadata() { + assert_support_helper_yields_to_visible_roles(PythonSupportSymbol::ObjectInput); + } + + #[test] + fn as_interface_helper_yields_to_visible_roles_without_renaming_metadata() { + assert_support_helper_yields_to_visible_roles(PythonSupportSymbol::AsInterface); + } + #[test] fn companion_aliases_freeze_roles_and_use_only_visible_symbols() { let owner = TypeIdentity::named(TypeIdentityKind::Class, "Audit", "Widget"); diff --git a/tools/dynwinrt-codegen/tests/python_as_interface_test.rs b/tools/dynwinrt-codegen/tests/python_as_interface_test.rs index d67bd7f1..7d27f182 100644 --- a/tools/dynwinrt-codegen/tests/python_as_interface_test.rs +++ b/tools/dynwinrt-codegen/tests/python_as_interface_test.rs @@ -4,9 +4,18 @@ mod common; use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; -use dynwinrt_codegen::codegen::python::generate_runtime_support_module; -use dynwinrt_codegen::meta::{ClassMeta, InterfaceMeta}; +use dynwinrt_codegen::codegen::python::{self, generate_runtime_support_module}; +use dynwinrt_codegen::meta::{self, ClassMeta, InterfaceMeta}; +use dynwinrt_codegen::types::TypeMeta; +use windows_metadata::{ + FieldAttributes, MethodAttributes, MethodCallAttributes, MethodImplAttributes, ParamAttributes, + Signature, Type, TypeAttributes, Value, writer, +}; const AS_INTERFACE: &str = " def as_interface(self, interface_class): return _dynwinrt_as_interface(self._obj, interface_class) @@ -89,3 +98,353 @@ fn as_interface_helper_rejects_runtime_classes_with_project_as_guidance() { assert!(helper.contains(expected), "missing {expected:?}:\n{helper}"); } } + +/// A metadata struct whose declaration takes the helper's module name. +const COLLIDING: &str = "_dynwinrt_as_interface"; +const ALIASED_IMPORT: &str = "_dynwinrt_as_interface as _dynwinrt_as_interface_2"; +const ALIASED_AS_INTERFACE: &str = " def as_interface(self, interface_class): + return _dynwinrt_as_interface_2(self._obj, interface_class) +"; +// Windows.Foundation.Uri implements both, so the generated module can cast a +// real object: IUriRuntimeClass and IUriRuntimeClassWithAbsoluteCanonicalUri. +const URI_RUNTIME_CLASS: (u32, u16, u16, [u8; 8]) = ( + 0x9e365e57, + 0x48b2, + 0x4160, + [0x95, 0x6f, 0xc7, 0x38, 0x51, 0x20, 0xbb, 0xfc], +); +const URI_ABSOLUTE_CANONICAL: (u32, u16, u16, [u8; 8]) = ( + 0x758d9661, + 0x221c, + 0x480f, + [0xa3, 0x39, 0x50, 0x65, 0x66, 0x73, 0xf4, 0x6f], +); + +static NEXT: AtomicU64 = AtomicU64::new(0); + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let directory = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!( + "python-as-interface-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir_all(&directory).unwrap(); + Self(directory) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn success(output: Output) { + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +fn python() -> PathBuf { + std::env::var_os("DYNWINRT_TEST_PYTHON") + .map(PathBuf::from) + .unwrap_or_else(|| { + let venv = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("bindings") + .join("py") + .join(".venv") + .join("Scripts") + .join("python.exe"); + if venv.is_file() { + venv + } else { + PathBuf::from("python") + } + }) +} + +fn runtime_available() -> bool { + let available = Command::new(python()) + .args([ + "-c", + "import dynwinrt; assert hasattr(dynwinrt, 'RO_INIT_MULTITHREADED')", + ]) + .output() + .is_ok_and(|output| output.status.success()); + assert!( + available || std::env::var("DYNWINRT_REQUIRE_IMPLEMENTATION_RUNTIME").as_deref() != Ok("1"), + "the as_interface collision probe requires the current Python binding" + ); + if !available { + eprintln!("Skipping the as_interface runtime probe; set DYNWINRT_TEST_PYTHON."); + } + available +} + +fn guid(file: &mut writer::File, definition: writer::TypeDef, value: (u32, u16, u16, [u8; 8])) { + let attribute = file.TypeRef("Windows.Foundation.Metadata", "GuidAttribute"); + let mut types = vec![Type::U32, Type::U16, Type::U16]; + types.extend(std::iter::repeat_n(Type::U8, 8)); + let constructor = file.MemberRef( + ".ctor", + &Signature { + flags: MethodCallAttributes::HASTHIS, + return_type: Type::Void, + types, + }, + writer::MemberRefParent::TypeRef(attribute), + ); + let (data1, data2, data3, data4) = value; + let mut arguments = vec![Value::U32(data1), Value::U16(data2), Value::U16(data3)]; + arguments.extend(data4.map(Value::U8)); + file.Attribute( + writer::HasAttribute::TypeDef(definition), + writer::AttributeType::MemberRef(constructor), + &arguments + .into_iter() + .map(|argument| (String::new(), argument)) + .collect::>(), + ); +} + +fn declare_interface(file: &mut writer::File, name: &str, value: (u32, u16, u16, [u8; 8])) { + let definition = file.TypeDef( + "Audit", + name, + writer::TypeDefOrRef::default(), + TypeAttributes::Public + | TypeAttributes::Interface + | TypeAttributes::Abstract + | TypeAttributes::WindowsRuntime, + ); + guid(file, definition, value); +} + +fn declare_method(file: &mut writer::File, name: &str, result: Type, params: &[(&str, Type)]) { + file.MethodDef( + name, + &Signature { + flags: MethodCallAttributes::HASTHIS, + return_type: result, + types: params.iter().map(|(_, typ)| typ.clone()).collect(), + }, + MethodAttributes::Public + | MethodAttributes::Abstract + | MethodAttributes::Virtual + | MethodAttributes::NewSlot, + MethodImplAttributes::default(), + ); + for (index, (name, _)) in params.iter().enumerate() { + file.Param(name, index as u16 + 1, ParamAttributes::In); + } +} + +/// `Audit.Widget` casts between two real Uri interfaces, while its default +/// interface also uses a struct declared with the helper's name. +fn colliding_metadata(path: &Path) { + let mut file = writer::File::new("PythonAsInterfaceCollision"); + let value_type = file.TypeRef("System", "ValueType"); + file.TypeDef( + "Audit", + COLLIDING, + writer::TypeDefOrRef::TypeRef(value_type), + TypeAttributes::Public + | TypeAttributes::Sealed + | TypeAttributes::SequentialLayout + | TypeAttributes::WindowsRuntime, + ); + file.Field("Value", &Type::I32, FieldAttributes::Public); + declare_interface(&mut file, "IWidget", URI_RUNTIME_CLASS); + declare_method(&mut file, "ReadAbsoluteUri", Type::String, &[]); + let colliding = Type::named("Audit", COLLIDING); + declare_method( + &mut file, + "Echo", + colliding.clone(), + &[("value", colliding)], + ); + declare_interface(&mut file, "IExtra", URI_ABSOLUTE_CANONICAL); + declare_method(&mut file, "ReadCanonicalUri", Type::String, &[]); + + let object = file.TypeRef("System", "Object"); + let class = file.TypeDef( + "Audit", + "Widget", + writer::TypeDefOrRef::TypeRef(object), + TypeAttributes::Public | TypeAttributes::Sealed | TypeAttributes::WindowsRuntime, + ); + let default = file.InterfaceImpl(class, &Type::named("Audit", "IWidget")); + file.InterfaceImpl(class, &Type::named("Audit", "IExtra")); + let attribute = file.TypeRef("Windows.Foundation.Metadata", "DefaultAttribute"); + let constructor = file.MemberRef( + ".ctor", + &Signature { + flags: MethodCallAttributes::HASTHIS, + return_type: Type::Void, + types: vec![], + }, + writer::MemberRefParent::TypeRef(attribute), + ); + file.Attribute( + writer::HasAttribute::InterfaceImpl(default), + writer::AttributeType::MemberRef(constructor), + &[], + ); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, file.into_stream()).unwrap(); +} + +/// Assert that a module which also declares or imports `COLLIDING` binds the +/// helper under an alias, and that every `as_interface()` calls that alias. +fn assert_aliased_as_interface(module: &str, expected_methods: usize) { + let (_, imports) = module + .split_once("from ._runtime import (") + .expect("runtime support import"); + let (imports, _) = imports.split_once(')').expect("closed import list"); + let imports = imports.split(',').map(str::trim).collect::>(); + assert!(imports.contains(&ALIASED_IMPORT), "{module}"); + assert!(!imports.contains(&COLLIDING), "{module}"); + assert_eq!( + module.matches("def as_interface(").count(), + expected_methods, + "{module}" + ); + assert_eq!( + module.matches(ALIASED_AS_INTERFACE).count(), + expected_methods, + "{module}" + ); + assert!( + !module.contains("return _dynwinrt_as_interface(self._obj"), + "{module}" + ); +} + +fn probe(directory: &Path, module: &str) { + fs::write( + directory.join("as_interface_probe.py"), + format!( + r#" +import importlib +import dynwinrt as dw + +support = importlib.import_module("pyviews._runtime") +module = importlib.import_module("pyviews.{module}") +Widget, IExtra = module.Widget, module.IExtra +assert module._dynwinrt_as_interface_2 is support._dynwinrt_as_interface +assert getattr(module, "{COLLIDING}", None) is not support._dynwinrt_as_interface + +factory_iid = dw.WinGUID.parse("44a9796f-723e-4fdf-a218-033e75b0c084") +factory_type = dw.DynWinRTType.register_interface( + "AsInterfaceCollisionFactory", factory_iid +).add_method( + "CreateUri", + dw.DynWinRTMethodSig().add_in(dw.DynWinRTType.hstring()).add_out(dw.DynWinRTType.object()), +) +uri = "https://example.com/as-interface" +with dw.RoApartment(), dw.projected_lifetime_scope(): + factory = dw.DynWinRTValue.activation_factory("Windows.Foundation.Uri").cast(factory_iid) + widget = Widget._from_native( + factory_type.method(6).invoke(factory, [dw.DynWinRTValue.from_hstring(uri)]) + ) + factory.release() + assert widget.read_absolute_uri() == uri + extra = widget.as_interface(IExtra) + assert type(extra) is IExtra + assert extra.read_canonical_uri() == uri + assert extra.as_interface(IExtra) is extra + try: + widget.as_interface(Widget) + except TypeError as error: + assert "dynwinrt.project_as(obj, Widget)" in str(error), error + else: + raise AssertionError("as_interface() accepted a runtime class") +print("as-interface-collision-ok") +"# + ), + ) + .unwrap(); + let output = Command::new(python()) + .args(["-B", "as_interface_probe.py"]) + .current_dir(directory) + .output() + .unwrap(); + assert!( + String::from_utf8_lossy(&output.stdout).contains("as-interface-collision-ok"), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + success(output); +} + +#[test] +fn colliding_metadata_declarations_alias_the_as_interface_helper() { + let fixture = Fixture::new(); + let winmd = fixture.0.join("metadata").join("Collision.winmd"); + colliding_metadata(&winmd); + let run_runtime = runtime_available(); + + // Standalone modules declare the struct inline, next to the class and its + // embedded IExtra view. + let class = meta::parse_class(winmd.to_str().unwrap(), "Audit", "Widget").unwrap(); + assert_eq!(class.required_interfaces.len(), 1); + let structs = python::package_structs(std::slice::from_ref(&class), &[]); + let identities = [TypeMeta::RuntimeClass { + namespace: "Audit".into(), + name: "Widget".into(), + default_interface: None, + } + .type_identity()] + .into_iter() + .chain(class.all_interfaces().map(InterfaceMeta::type_identity)) + .chain(structs.iter().map(TypeMeta::type_identity)); + let context = python::PythonProjectionContext::new(identities, false).unwrap(); + let source = python::generate_class(&context, &class, &Default::default()); + assert!( + source.contains(&format!("\nclass {COLLIDING}:")), + "{source}" + ); + assert_aliased_as_interface(&source, 2); + let standalone = fixture.0.join("standalone"); + let package = standalone.join("pyviews"); + fs::create_dir_all(&package).unwrap(); + fs::write(package.join("__init__.py"), "").unwrap(); + fs::write( + package.join("_runtime.py"), + generate_runtime_support_module(), + ) + .unwrap(); + fs::write(package.join("audit__widget.py"), &source).unwrap(); + if run_runtime { + probe(&standalone, "audit__widget"); + } + + // Packaged output resolves the struct from its own module. + let packaged = fixture.0.join("packaged"); + success( + Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")) + .args(["generate", "--winmd"]) + .arg(&winmd) + .arg("--output") + .arg(packaged.join("pyviews")) + .args(["--lang", "py", "--class-name", "Audit.Widget"]) + .output() + .unwrap(), + ); + let source = fs::read_to_string(packaged.join("pyviews").join("audit__widget.py")).unwrap(); + assert_aliased_as_interface(&source, 2); + if run_runtime { + probe(&packaged, "audit__widget"); + } +} From 6f1e6ffa74f19351c96f8cec6bde31ab54588e07 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 14:21:01 +0800 Subject: [PATCH 07/12] Name every release path in the released-object error The released error blamed only projected_lifetime_scope() and release_projected(), but the public DynWinRTValue.release() reaches the same state. `raw.release(); raw.cast(iid)` pointed at two operations that never happened. Receiver and argument errors now share one accurate reason: "has been released (its projected_lifetime_scope() exited, or release_projected() / DynWinRTValue.release() was called) and can no longer be used." Tests and the E2E check assert the full text and cover a direct DynWinRTValue.release() on raw and projected values. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/src/errors.rs | 11 ++++------- bindings/py/tests/test_error_messages.py | 23 ++++++++++++++++++----- tests/e2e/runners/py_runner.py | 21 +++++++++++++-------- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/bindings/py/src/errors.rs b/bindings/py/src/errors.rs index dc823075..4df70f89 100644 --- a/bindings/py/src/errors.rs +++ b/bindings/py/src/errors.rs @@ -24,21 +24,18 @@ fn hresult_hint(code: HRESULT) -> Option<&'static str> { .find_map(|&(hinted, hint)| (hinted == code).then_some(hint)) } -const RELEASED_REASON: &str = "its projected_lifetime_scope() exited or release_projected() \ - was called. Use the object inside its scope, or don't release it."; +const RELEASED_REASON: &str = "has been released (its projected_lifetime_scope() exited, or \ + release_projected() / DynWinRTValue.release() was called) and can no longer be used."; /// A call on a value after `release()`, including release by its lifetime scope. pub(crate) fn released_receiver_error() -> PyErr { - PyRuntimeError::new_err(format!( - "This WinRT object has been released: {RELEASED_REASON}" - )) + PyRuntimeError::new_err(format!("This WinRT object {RELEASED_REASON}")) } /// A released value passed as argument `position` (0-based) of `operation`. pub(crate) fn released_argument_error(operation: &str, position: usize) -> PyErr { PyRuntimeError::new_err(format!( - "This WinRT object (argument {position} of {operation}) has been released: \ - {RELEASED_REASON}" + "This WinRT object (argument {position} of {operation}) {RELEASED_REASON}" )) } diff --git a/bindings/py/tests/test_error_messages.py b/bindings/py/tests/test_error_messages.py index e91b8af8..98315c17 100644 --- a/bindings/py/tests/test_error_messages.py +++ b/bindings/py/tests/test_error_messages.py @@ -45,11 +45,11 @@ "(or call `dynwinrt.ro_initialize(dynwinrt.RO_INIT_MULTITHREADED)`) before " "calling WinRT APIs." ) -RELEASED_REASON = ( - r"has been released: its projected_lifetime_scope\(\) exited or " - r"release_projected\(\) was called\. Use the object inside its scope, or " - r"don't release it\.$" -) +RELEASED_REASON = re.escape( + "has been released (its projected_lifetime_scope() exited, or " + "release_projected() / DynWinRTValue.release() was called) and can no longer " + "be used." +) + "$" RELEASED = rf"^This WinRT object {RELEASED_REASON}" _URI_FACTORY = DynWinRTType.register_interface( @@ -147,6 +147,19 @@ def test_projection_used_after_release_projected_explains_the_release(): _assert_released(uri) +def test_projection_used_after_direct_value_release_explains_the_release(): + with RoApartment(): + uri = ProjectedUri.create("https://example.com/direct") + uri._obj.release() + _assert_released(uri) + + value = DynWinRTValue.activation_factory("Windows.Foundation.Uri") + value.release() + with pytest.raises(RuntimeError, match=RELEASED) as caught: + value.cast(IID_IURI_FACTORY) + assert "DynWinRTValue.release()" in str(caught.value) + + def test_receivers_report_released_values_null_and_value_kinds(): method = _URI.method(6) receivers = ( diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index bd4a8a53..50bcee5c 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -354,14 +354,23 @@ async def run_check( cr['error'] = 'as_interface() accepted a runtime class' elif kind == 'released_projection_error': + reason = ( + 'has been released (its projected_lifetime_scope() exited, or ' + 'release_projected() / DynWinRTValue.release() was called) and ' + 'can no longer be used.' + ) + args = [literal_arg(a) for a in check.get('args', [])] with dw.projected_lifetime_scope(): - scoped = cls(*[literal_arg(a) for a in check.get('args', [])]) - released = cls(*[literal_arg(a) for a in check.get('args', [])]) + scoped = cls(*args) + released = cls(*args) dw.release_projected(released) - live = cls(*[literal_arg(a) for a in check.get('args', [])]) + value_released = cls(*args) + value_released._obj.release() + live = cls(*args) uses = ( ('scope exit', lambda: getattr(scoped, member)), ('release_projected', lambda: getattr(released, member)), + ('DynWinRTValue.release()', lambda: getattr(value_released, member)), ('interface cast', released.to_string), ('argument', lambda: live.equals(released)), ) @@ -370,11 +379,7 @@ async def run_check( use() except RuntimeError as error: message = str(error) - if ( - 'has been released' not in message - or 'projected_lifetime_scope()' not in message - or 'release_projected()' not in message - ): + if not message.endswith(reason): cr['error'] = f'{label}: unclear released-object error: {message}' return cr else: From c8f007f034e52482f388693f9e3e2834ccb46676 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 20:12:09 +0800 Subject: [PATCH 08/12] Keep AttributeError for non-runtime-class as_interface() targets _dynwinrt_as_interface raised TypeError for every target without from_value. Previously None, an int, an arbitrary class, a runtime-class instance, or a generated interface without an IID raised AttributeError from the from_value lookup, so they changed exception type too. Only a generated runtime class (a type carrying a runtime-class or projectable-class marker, without from_value) now raises the TypeError pointing to dynwinrt.project_as(). Every other target calls interface_class.from_value(native) exactly as before. The codegen test pins the helper shape. The generated-module probe and the E2E check assert TypeError for runtime classes and AttributeError for the other targets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/e2e/runners/py_runner.py | 19 ++++++++- .../src/codegen/winrt/python/generator/mod.rs | 16 ++++---- .../tests/python_as_interface_test.rs | 40 +++++++++++++++---- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index 50bcee5c..8d13259d 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -348,10 +348,25 @@ async def run_check( expected = f'dynwinrt.project_as(obj, {cls.__name__})' if expected not in str(error): cr['error'] = f'TypeError did not suggest {expected}: {error}' - else: - cr['pass'] = True + return cr else: cr['error'] = 'as_interface() accepted a runtime class' + return cr + # Other misuse keeps the AttributeError of the from_value lookup. + for target in (None, 42, object, obj): + try: + obj.as_interface(target) + except AttributeError: + continue + except Exception as error: + cr['error'] = ( + f'as_interface({target!r}) raised {type(error).__name__}, ' + 'expected AttributeError' + ) + return cr + cr['error'] = f'as_interface({target!r}) succeeded' + return cr + cr['pass'] = True elif kind == 'released_projection_error': reason = ( diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs index dc996d82..aead89dc 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -132,18 +132,20 @@ def _dynwinrt_can_cast(value, iid): def _dynwinrt_as_interface(native, interface_class): - from_value = getattr(interface_class, 'from_value', None) - if from_value is not None: - return from_value(native) - name = getattr(interface_class, '__name__', type(interface_class).__name__) - if getattr(interface_class, '_dynwinrt_runtime_class_type', False) or getattr( - interface_class, '_dynwinrt_projectable_class_type', False + if ( + isinstance(interface_class, type) + and ( + getattr(interface_class, '_dynwinrt_runtime_class_type', False) + or getattr(interface_class, '_dynwinrt_projectable_class_type', False) + ) + and not hasattr(interface_class, 'from_value') ): + name = interface_class.__name__ raise TypeError( f'as_interface() requires a generated interface class, but {name} is a ' f'runtime class. Use dynwinrt.project_as(obj, {name}) to cast to a runtime class.' ) - raise TypeError(f'as_interface() requires a generated interface class, not {name}.') + return interface_class.from_value(native) \n"; /// `as_interface()`, emitted for every generated class and interface view. diff --git a/tools/dynwinrt-codegen/tests/python_as_interface_test.rs b/tools/dynwinrt-codegen/tests/python_as_interface_test.rs index 7d27f182..be2e8b18 100644 --- a/tools/dynwinrt-codegen/tests/python_as_interface_test.rs +++ b/tools/dynwinrt-codegen/tests/python_as_interface_test.rs @@ -79,24 +79,36 @@ fn every_as_interface_delegates_to_the_runtime_support_helper() { } #[test] -fn as_interface_helper_rejects_runtime_classes_with_project_as_guidance() { +fn as_interface_helper_changes_only_runtime_class_misuse() { let runtime = generate_runtime_support_module(); let helper = runtime .split_once("def _dynwinrt_as_interface(native, interface_class):\n") .map(|(_, helper)| helper.split("\n\n\n").next().unwrap_or(helper)) .expect("as_interface helper"); + // Only a generated runtime class without from_value raises TypeError. + let (guard, fallback) = helper + .split_once(" raise TypeError(") + .expect("runtime-class TypeError"); for expected in [ - "from_value = getattr(interface_class, 'from_value', None)", - "return from_value(native)", + "isinstance(interface_class, type)", "_dynwinrt_runtime_class_type", "_dynwinrt_projectable_class_type", - "raise TypeError(", - "as_interface() requires a generated interface class, but {name} is a", - "Use dynwinrt.project_as(obj, {name}) to cast to a runtime class.", - "as_interface() requires a generated interface class, not {name}.", + "not hasattr(interface_class, 'from_value')", ] { - assert!(helper.contains(expected), "missing {expected:?}:\n{helper}"); + assert!(guard.contains(expected), "missing {expected:?}:\n{helper}"); } + assert!( + fallback.contains("Use dynwinrt.project_as(obj, {name}) to cast to a runtime class."), + "{helper}" + ); + // Every other target keeps the original from_value lookup and its errors. + assert!( + fallback + .trim_end() + .ends_with("return interface_class.from_value(native)"), + "{helper}" + ); + assert_eq!(helper.matches("raise ").count(), 1, "{helper}"); } /// A metadata struct whose declaration takes the helper's module name. @@ -369,6 +381,18 @@ with dw.RoApartment(), dw.projected_lifetime_scope(): assert "dynwinrt.project_as(obj, Widget)" in str(error), error else: raise AssertionError("as_interface() accepted a runtime class") + + class NoIidInterface: + _dynwinrt_interface_type = True + + # Other misuse keeps its original AttributeError from the from_value lookup. + for target in (None, 42, object, NoIidInterface, widget): + try: + widget.as_interface(target) + except AttributeError as error: + assert "from_value" in str(error), error + else: + raise AssertionError(f"as_interface() accepted {{target!r}}") print("as-interface-collision-ok") "# ), From 5aaed4dad2610b9f12be26c74de762184411c9a8 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 20:12:32 +0800 Subject: [PATCH 09/12] Reject released values nested in collection, array and struct inputs Released wrappers inside Python sequences, mappings, arrays or struct fields still reached native code as WinRT null. Generated conversions pass each element's _obj through DynWinRTValue.create_vector, create_map, DynWinRTArray.from_values / from_object_values and DynWinRTStruct.set_object, which copied the payload without a lifecycle check. For example, obj.method([released_view]) for an IIterable parameter sent a vector containing null. These constructors now validate every input through the same check as native_arguments(): vector and array elements, map keys and values, and struct object fields. The error names the operation and the slot, e.g. "This WinRT object (element 1 of DynWinRTArray.from_values()) has been released (...)". unbox_object() now raises for a released value instead of returning None, so released and WinRT null stay distinct. Live values and real WinRT nulls are unaffected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 12 ++-- bindings/py/src/errors.rs | 29 +++++++- bindings/py/src/runtime.rs | 82 ++++++++++++++-------- bindings/py/tests/test_error_messages.py | 87 +++++++++++++++++++++++- 4 files changed, 172 insertions(+), 38 deletions(-) diff --git a/bindings/py/README.md b/bindings/py/README.md index 735e41ae..04540aaf 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -490,11 +490,13 @@ with RoApartment(RO_INIT_SINGLETHREADED), projected_lifetime_scope(): Scopes nest in LIFO order. Wrappers that survive a closed scope remain Python objects, but their native values are released: using one afterwards, as the -object of a call or as an argument, raises `RuntimeError` explaining that it -was released, as it does after `release_projected(wrapper)`. Each scope is -thread-affine: enter, use, and close it inside that thread's `RoApartment`. -Same-thread asyncio tasks inherit the active scope, while worker threads must -open their own ordered `with RoApartment(...), projected_lifetime_scope():`. +object of a call, as an argument, or inside a sequence, mapping, array, or +struct input, raises `RuntimeError` explaining that it was released, as it +does after `release_projected(wrapper)` or `DynWinRTValue.release()`. Each +scope is thread-affine: enter, use, and close it inside that thread's +`RoApartment`. Same-thread asyncio tasks inherit the active scope, while worker +threads must open their own ordered +`with RoApartment(...), projected_lifetime_scope():`. Native callbacks invoked on a foreign thread preserve other captured context but do not inherit the creator thread's lifetime scope. This includes generated delegates, raw progress diff --git a/bindings/py/src/errors.rs b/bindings/py/src/errors.rs index 4df70f89..2462668a 100644 --- a/bindings/py/src/errors.rs +++ b/bindings/py/src/errors.rs @@ -32,10 +32,33 @@ pub(crate) fn released_receiver_error() -> PyErr { PyRuntimeError::new_err(format!("This WinRT object {RELEASED_REASON}")) } -/// A released value passed as argument `position` (0-based) of `operation`. -pub(crate) fn released_argument_error(operation: &str, position: usize) -> PyErr { +/// Where a value was passed to an operation, with a 0-based index. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum InputSlot { + Argument(usize), + Element(usize), + Key(usize), + Value(usize), + Field(usize), +} + +impl std::fmt::Display for InputSlot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (slot, index) = match *self { + Self::Argument(index) => ("argument", index), + Self::Element(index) => ("element", index), + Self::Key(index) => ("key", index), + Self::Value(index) => ("value", index), + Self::Field(index) => ("field", index), + }; + write!(f, "{slot} {index}") + } +} + +/// A released value passed in `slot` of `operation`. +pub(crate) fn released_input_error(operation: &str, slot: InputSlot) -> PyErr { PyRuntimeError::new_err(format!( - "This WinRT object (argument {position} of {operation}) {RELEASED_REASON}" + "This WinRT object ({slot} of {operation}) {RELEASED_REASON}" )) } diff --git a/bindings/py/src/runtime.rs b/bindings/py/src/runtime.rs index 6fb00b7f..2166230f 100644 --- a/bindings/py/src/runtime.rs +++ b/bindings/py/src/runtime.rs @@ -13,8 +13,8 @@ use windows::Win32::System::WinRT::{ use windows::core::{GUID, HSTRING, IUnknown, Interface}; use crate::errors::{ - map_dynwinrt_error, map_dynwinrt_error_with_context, map_windows_error, - non_object_receiver_error, released_argument_error, released_receiver_error, + InputSlot, map_dynwinrt_error, map_dynwinrt_error_with_context, map_windows_error, + non_object_receiver_error, released_input_error, released_receiver_error, }; /// Shared MetadataTable — created once, used everywhere. @@ -321,6 +321,8 @@ pub fn unbox_object(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult>()?; + // A released value is not a WinRT null; do not unbox it as `None`. + raw.check_input("unbox_object()", InputSlot::Argument(0))?; dynwinrt::unbox_property_value(&raw.0).map_err(map_dynwinrt_error)? }; match result { @@ -1239,29 +1241,40 @@ impl DynWinRTValue { } } - /// Reject this value as argument `position` of `operation` if released. - fn check_argument(&self, operation: &str, position: usize) -> PyResult<()> { + /// Reject this value if released; `slot` names where `operation` received it. + fn check_input(&self, operation: &str, slot: InputSlot) -> PyResult<()> { match self.1 { Lifecycle::Live => Ok(()), - Lifecycle::Released => Err(released_argument_error(operation, position)), + Lifecycle::Released => Err(released_input_error(operation, slot)), } } } -/// The native arguments of `operation`, rejecting released values. -pub(crate) fn native_arguments( +/// The native values `operation` received, rejecting released values. `slot` +/// maps each position to where it was passed, such as an argument or element. +fn native_inputs( operation: &str, - args: Vec, + values: Vec, + slot: fn(usize) -> InputSlot, ) -> PyResult> { - args.into_iter() + values + .into_iter() .enumerate() - .map(|(position, arg)| { - arg.check_argument(operation, position)?; - Ok(arg.0) + .map(|(index, value)| { + value.check_input(operation, slot(index))?; + Ok(value.0) }) .collect() } +/// The native arguments of `operation`, rejecting released values. +pub(crate) fn native_arguments( + operation: &str, + args: Vec, +) -> PyResult> { + native_inputs(operation, args, InputSlot::Argument) +} + fn value_kind(value: &dynwinrt::WinRTValue) -> &'static str { use dynwinrt::WinRTValue; match value { @@ -1455,8 +1468,8 @@ impl DynWinRTValue { items: Vec, element_type: &DynWinRTType, ) -> PyResult { + let wrt_items = native_inputs("DynWinRTValue.create_vector()", items, InputSlot::Element)?; let iids = TABLE.vector_iids(&element_type.0); - let wrt_items: Vec = items.iter().map(|i| i.0.clone()).collect(); let vector = dynwinrt::vector::create_vector_from_values(&wrt_items, &element_type.0, iids) .map_err(map_dynwinrt_error)?; Ok(DynWinRTValue::new(dynwinrt::WinRTValue::Object(vector))) @@ -1475,12 +1488,12 @@ impl DynWinRTValue { "create_map: keys and values must have the same length", )); } + const OPERATION: &str = "DynWinRTValue.create_map()"; + let keys = native_inputs(OPERATION, keys, InputSlot::Key)?; + let values = native_inputs(OPERATION, values, InputSlot::Value)?; let iids = TABLE.map_iids(&key_type.0, &value_type.0); - let entries: Vec<(dynwinrt::WinRTValue, dynwinrt::WinRTValue)> = keys - .iter() - .zip(values.iter()) - .map(|(key, value)| (key.0.clone(), value.0.clone())) - .collect(); + let entries: Vec<(dynwinrt::WinRTValue, dynwinrt::WinRTValue)> = + keys.into_iter().zip(values).collect(); let map = dynwinrt::map::create_map_from_values(&entries, &key_type.0, &value_type.0, iids) .map_err(map_dynwinrt_error)?; Ok(DynWinRTValue::new(dynwinrt::WinRTValue::Object(map))) @@ -1769,7 +1782,7 @@ impl DynWinRTValue { v1: &DynWinRTValue, ) -> PyResult { let obj_raw = self.com_receiver("call_1()")?.as_raw(); - v1.check_argument("call_1()", 0)?; + v1.check_input("call_1()", InputSlot::Argument(0))?; let in_type = TABLE.handle_from_kind(v1.0.get_type_kind()); let method = dynwinrt::MethodSignature::new(&*TABLE) .add_in(in_type) @@ -1850,6 +1863,20 @@ impl DynWinRTValue { #[derive(Clone)] pub struct DynWinRTArray(dynwinrt::ArrayData); +impl DynWinRTArray { + fn from_elements( + operation: &str, + values: Vec, + element_type: &DynWinRTType, + ) -> PyResult { + let values = native_inputs(operation, values, InputSlot::Element)?; + Ok(Self(dynwinrt::ArrayData::from_values( + element_type.0.clone(), + &values, + ))) + } +} + #[pymethods] impl DynWinRTArray { fn __len__(&self) -> usize { @@ -2055,13 +2082,11 @@ impl DynWinRTArray { } #[staticmethod] - fn from_values(values: Vec, element_type: &DynWinRTType) -> DynWinRTArray { - let values: Vec = - values.iter().map(|value| value.0.clone()).collect(); - DynWinRTArray(dynwinrt::ArrayData::from_values( - element_type.0.clone(), - &values, - )) + fn from_values( + values: Vec, + element_type: &DynWinRTType, + ) -> PyResult { + Self::from_elements("DynWinRTArray.from_values()", values, element_type) } /// Build a DynWinRTArray of WinRT object/interface elements. @@ -2074,8 +2099,8 @@ impl DynWinRTArray { fn from_object_values( values: Vec, element_type: &DynWinRTType, - ) -> DynWinRTArray { - Self::from_values(values, element_type) + ) -> PyResult { + Self::from_elements("DynWinRTArray.from_object_values()", values, element_type) } /// Return the u8 array data as a Python `bytes` object. Safe for both @@ -2344,6 +2369,7 @@ impl DynWinRTStruct { fn set_object(&mut self, index: i64, value: &DynWinRTValue) -> PyResult<()> { let index = checked_index(index)?; + value.check_input("DynWinRTStruct.set_object()", InputSlot::Field(index))?; match &value.0 { dynwinrt::WinRTValue::Object(obj) => self .0 diff --git a/bindings/py/tests/test_error_messages.py b/bindings/py/tests/test_error_messages.py index 98315c17..cccc77c4 100644 --- a/bindings/py/tests/test_error_messages.py +++ b/bindings/py/tests/test_error_messages.py @@ -17,7 +17,9 @@ from dynwinrt import ( RO_INIT_MULTITHREADED, RO_INIT_SINGLETHREADED, + DynWinRTArray, DynWinRTMethodSig, + DynWinRTStruct, DynWinRTType, DynWinRTValue, DynWinRtDelegate, @@ -27,6 +29,7 @@ release_projected, ro_initialize, ro_uninitialize, + unbox_object, ) from dynwinrt.dynwinrt import ( _dynwinrt_cache_projected, @@ -38,6 +41,7 @@ IID_IURI = WinGUID.parse("9E365E57-48B2-4160-956F-C7385120BBFC") IID_ISTRINGABLE = WinGUID.parse("96369F54-8EB6-48F0-ABCE-C1B211E627C3") IID_TEST_DELEGATE = WinGUID.parse("5A0F1C3E-7B24-4D69-8E1F-2C3B4A5D6E7F") +IID_IPROPERTY_VALUE_STATICS = WinGUID.parse("629BDBC8-D932-4FF4-96B9-8D96C5C1E858") RPC_E_CHANGED_MODE = -2147417850 CO_E_NOTINITIALIZED = -2147221008 NOT_INITIALIZED_HINT = ( @@ -66,13 +70,17 @@ ).add_method("ToString", DynWinRTMethodSig().add_out(DynWinRTType.hstring())) -def released_argument(position, operation): +def released_input(slot, operation): return ( - rf"^This WinRT object \(argument {position} of {re.escape(operation)}\) " + rf"^This WinRT object \({slot} of {re.escape(operation)}\) " rf"{RELEASED_REASON}" ) +def released_argument(position, operation): + return released_input(f"argument {position}", operation) + + class ProjectedUri: """The generated runtime-class wrapper shape, reduced to two members.""" @@ -224,6 +232,81 @@ def test_released_arguments_are_rejected_by_position(): release_projected(other) +def test_released_values_nested_in_inputs_are_rejected_by_position(): + with RoApartment(): + live = DynWinRTValue.activation_factory("Windows.Foundation.Uri") + released = _released_uri_value() + key = DynWinRTValue.from_hstring("key") + objects = DynWinRTType.object() + try: + vector = DynWinRTValue.create_vector([live, DynWinRTValue.null_value()], objects) + vector.release() + with pytest.raises( + RuntimeError, + match=released_input("element 1", "DynWinRTValue.create_vector()"), + ): + DynWinRTValue.create_vector([live, released], objects) + + strings = DynWinRTType.hstring() + mapping = DynWinRTValue.create_map([key], [live], strings, objects) + mapping.release() + with pytest.raises( + RuntimeError, match=released_input("key 0", "DynWinRTValue.create_map()") + ): + DynWinRTValue.create_map([released], [live], objects, objects) + with pytest.raises( + RuntimeError, match=released_input("value 1", "DynWinRTValue.create_map()") + ): + DynWinRTValue.create_map( + [key, DynWinRTValue.from_hstring("other")], + [live, released], + strings, + objects, + ) + + for operation, build in ( + ("DynWinRTArray.from_values()", DynWinRTArray.from_values), + ("DynWinRTArray.from_object_values()", DynWinRTArray.from_object_values), + ): + assert len(build([live, DynWinRTValue.null_value()], objects)) == 2 + with pytest.raises(RuntimeError, match=released_input("element 1", operation)): + build([live, released], objects) + + fields = DynWinRTStruct.create( + DynWinRTType.struct_type("Tests.ReleasedObjectField", [objects]) + ) + fields.set_object(0, live) + fields.set_object(0, DynWinRTValue.null_value()) + assert fields.get_object(0).is_null() + with pytest.raises( + RuntimeError, match=released_input("field 0", "DynWinRTStruct.set_object()") + ): + fields.set_object(0, released) + finally: + live.release() + + +def test_unbox_object_distinguishes_released_values_from_null(): + assert unbox_object(None) is None + assert unbox_object(DynWinRTValue.null_value()) is None + with RoApartment(): + statics = DynWinRTValue.activation_factory("Windows.Foundation.PropertyValue").cast( + IID_IPROPERTY_VALUE_STATICS + ) + # IPropertyValueStatics.CreateString is vtable slot 18. + boxed = statics.call( + 18, + DynWinRTType.object(), + [DynWinRTType.hstring()], + [DynWinRTValue.from_hstring("boxed")], + ) + statics.release() + assert unbox_object(boxed) == "boxed" + boxed.release() + with pytest.raises(RuntimeError, match=released_argument(0, "unbox_object()")): + unbox_object(boxed) + + def test_delegate_invocation_rejects_released_receivers_and_arguments(): signature = DynWinRTMethodSig().add_in(DynWinRTType.object()) calls = [] From 50cbd9d03e442837ea6593bd224ef66685122cef Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 20:12:54 +0800 Subject: [PATCH 10/12] Make the E2E released-argument case reach the argument check The "argument" case called live.equals(released). Its runtime-class parameter is cast before invocation, so the error came from cast()'s receiver check, and the case would still pass without the released-argument check. The check now pins the exact message for each use: - the runtime-class parameter keeps its receiver-style error; - PropertyValue.create_inspectable(released) passes an Object parameter without a cast and must report "(argument 0 of invoke())"; - create_inspectable_array([live, released]) must report "(element 1 of DynWinRTArray.from_values())". With the argument check reverted, the check fails: native code receives null and returns E_INVALIDARG. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/e2e/runners/py_runner.py | 36 +++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index 8d13259d..fa49c605 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -374,6 +374,7 @@ async def run_check( 'release_projected() / DynWinRTValue.release() was called) and ' 'can no longer be used.' ) + receiver = f'This WinRT object {reason}' args = [literal_arg(a) for a in check.get('args', [])] with dw.projected_lifetime_scope(): scoped = cls(*args) @@ -382,20 +383,37 @@ async def run_check( value_released = cls(*args) value_released._obj.release() live = cls(*args) + property_value = generated_type(pkg_name, 'PropertyValue') uses = ( - ('scope exit', lambda: getattr(scoped, member)), - ('release_projected', lambda: getattr(released, member)), - ('DynWinRTValue.release()', lambda: getattr(value_released, member)), - ('interface cast', released.to_string), - ('argument', lambda: live.equals(released)), + ('scope exit', lambda: getattr(scoped, member), receiver), + ('release_projected', lambda: getattr(released, member), receiver), + ( + 'DynWinRTValue.release()', + lambda: getattr(value_released, member), + receiver, + ), + ('interface cast', released.to_string, receiver), + # A runtime-class parameter is cast first, which receives it. + ('runtime-class argument', lambda: live.equals(released), receiver), + # An Object parameter reaches the native invocation unchanged. + ( + 'object argument', + lambda: property_value.create_inspectable(released), + f'This WinRT object (argument 0 of invoke()) {reason}', + ), + ( + 'array element', + lambda: property_value.create_inspectable_array([live, released]), + 'This WinRT object (element 1 of ' + f'DynWinRTArray.from_values()) {reason}', + ), ) - for label, use in uses: + for label, use, expected in uses: try: use() except RuntimeError as error: - message = str(error) - if not message.endswith(reason): - cr['error'] = f'{label}: unclear released-object error: {message}' + if str(error) != expected: + cr['error'] = f'{label}: expected {expected!r}, got {str(error)!r}' return cr else: cr['error'] = f'{label}: released projection allowed a WinRT call' From 9594433b85d6027cce572b93f4115ff2f5ffb271 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 21:38:19 +0800 Subject: [PATCH 11/12] Stop generated code from turning released values into WinRT nulls The generated _implementation_reference() returned a fresh DynWinRTValue.null_value() whenever raw.is_null(). is_null() is also true after release(), so a released wrapper became a live null and passed every lifecycle check. This reached native code as null through: - arguments to generated delegate callables inside implementations, e.g. a stored event handler invoked with a released sender; - array elements and struct object fields in implementation results; - direct return and out values, which the binding extracted without a lifecycle check; - DynWinRTValue.box_reference(released, t); - generated struct IReference field setters, whose _dynwinrt_unbox_reference() mapped a released wrapper to None. Fixes: - _implementation_reference() returns a null or released raw value itself, so a real null stays a live null and a released value stays released. - Implementation callback outputs pass through native_outputs(). A released output fails the native call like any other handler error: PYWINRT_E_UNRAISABLE_PYTHON_EXCEPTION, sys.unraisablehook, and take_error(), e.g. "(output 1 of implementation callback)". - box_reference() checks its input. - DynWinRTValue.is_released() tells a released value from a WinRT null. _dynwinrt_unbox_reference() calls it through getattr, so generated code still works with runtimes that lack it. A released wrapper then raises when its value is read. Tests cover each path and that real nulls still work: - pytest: is_released(), box_reference(), and an implementation that returns released out and result values; - a new implementation E2E case: stored delegate, array element, direct result, and the struct field helper; - the E2E IReference check: property setter and unbox helper; - a codegen test that runs a generated implementation whose struct field, out parameter and result are released. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 11 +- bindings/py/dynwinrt.pyi | 3 + bindings/py/src/errors.rs | 5 +- bindings/py/src/implementation.rs | 8 +- bindings/py/src/runtime.rs | 19 ++ bindings/py/tests/test_error_messages.py | 72 ++++++ tests/e2e/runners/implementation_py.py | 131 +++++++++- tests/e2e/runners/py_runner.py | 39 ++- .../src/codegen/winrt/python/generator/mod.rs | 7 +- .../codegen/winrt/python/implementation.rs | 4 +- .../python_released_implementation_test.rs | 241 ++++++++++++++++++ 11 files changed, 524 insertions(+), 16 deletions(-) create mode 100644 tools/dynwinrt-codegen/tests/python_released_implementation_test.rs diff --git a/bindings/py/README.md b/bindings/py/README.md index 04540aaf..dd432d19 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -492,10 +492,13 @@ Scopes nest in LIFO order. Wrappers that survive a closed scope remain Python objects, but their native values are released: using one afterwards, as the object of a call, as an argument, or inside a sequence, mapping, array, or struct input, raises `RuntimeError` explaining that it was released, as it -does after `release_projected(wrapper)` or `DynWinRTValue.release()`. Each -scope is thread-affine: enter, use, and close it inside that thread's -`RoApartment`. Same-thread asyncio tasks inherit the active scope, while worker -threads must open their own ordered +does after `release_projected(wrapper)` or `DynWinRTValue.release()`. +Returning one from an interface implementation handler fails the native call +like any other handler error. `DynWinRTValue.is_released()` tells a released +value apart from a WinRT null reference: both report `is_null()`, but only the +null can still be passed. Each scope is thread-affine: enter, use, and close it +inside that thread's `RoApartment`. Same-thread asyncio tasks inherit the +active scope, while worker threads must open their own ordered `with RoApartment(...), projected_lifetime_scope():`. Native callbacks invoked on a foreign thread preserve other captured context but do not inherit the diff --git a/bindings/py/dynwinrt.pyi b/bindings/py/dynwinrt.pyi index b82d58fa..2d77c677 100644 --- a/bindings/py/dynwinrt.pyi +++ b/bindings/py/dynwinrt.pyi @@ -466,6 +466,9 @@ class DynWinRTValue: def to_guid(self) -> WinGUID: ... def to_bytes(self) -> bytes: ... def is_null(self) -> bool: ... + # True after release(), release_projected(), or a closing + # projected_lifetime_scope(); a WinRT null reference is not released. + def is_released(self) -> bool: ... def release(self) -> None: ... def as_raw(self) -> int: ... def identity_raw(self) -> int: ... diff --git a/bindings/py/src/errors.rs b/bindings/py/src/errors.rs index 2462668a..340ece66 100644 --- a/bindings/py/src/errors.rs +++ b/bindings/py/src/errors.rs @@ -32,7 +32,7 @@ pub(crate) fn released_receiver_error() -> PyErr { PyRuntimeError::new_err(format!("This WinRT object {RELEASED_REASON}")) } -/// Where a value was passed to an operation, with a 0-based index. +/// Where a value was handed to native code, with a 0-based index. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum InputSlot { Argument(usize), @@ -40,6 +40,8 @@ pub(crate) enum InputSlot { Key(usize), Value(usize), Field(usize), + /// A value a Python callback returned to its native caller. + Output(usize), } impl std::fmt::Display for InputSlot { @@ -50,6 +52,7 @@ impl std::fmt::Display for InputSlot { Self::Key(index) => ("key", index), Self::Value(index) => ("value", index), Self::Field(index) => ("field", index), + Self::Output(index) => ("output", index), }; write!(f, "{slot} {index}") } diff --git a/bindings/py/src/implementation.rs b/bindings/py/src/implementation.rs index 11ed4329..1ca498c0 100644 --- a/bindings/py/src/implementation.rs +++ b/bindings/py/src/implementation.rs @@ -20,7 +20,7 @@ use windows::core::{Error, HRESULT}; use crate::errors::map_windows_error; use crate::runtime::{ DynWinRTMethodSig, DynWinRTType, DynWinRTValue, PYWINRT_E_UNRAISABLE_PYTHON_EXCEPTION, WinGUID, - wrap_python_callback_context, + native_outputs, wrap_python_callback_context, }; const RO_E_CLOSED: HRESULT = HRESULT(0x80000013_u32 as i32); @@ -210,11 +210,7 @@ impl CallbackCell { .collect::>>()?; let inputs = PyList::new(py, inputs)?; let outputs = callback.call1(py, (interface_index, vtable_index, inputs))?; - Ok(outputs - .extract::>(py)? - .into_iter() - .map(|value| value.0) - .collect()) + native_outputs("implementation callback", outputs.extract(py)?) })(); result.map_err(|error| { let message = format!( diff --git a/bindings/py/src/runtime.rs b/bindings/py/src/runtime.rs index 2166230f..618b7b5e 100644 --- a/bindings/py/src/runtime.rs +++ b/bindings/py/src/runtime.rs @@ -1275,6 +1275,15 @@ pub(crate) fn native_arguments( native_inputs(operation, args, InputSlot::Argument) } +/// The native values a Python `operation` callback returned, rejecting +/// released values instead of returning them as WinRT null. +pub(crate) fn native_outputs( + operation: &str, + outputs: Vec, +) -> PyResult> { + native_inputs(operation, outputs, InputSlot::Output) +} + fn value_kind(value: &dynwinrt::WinRTValue) -> &'static str { use dynwinrt::WinRTValue; match value { @@ -1442,6 +1451,7 @@ impl DynWinRTValue { #[staticmethod] fn box_reference(value: &DynWinRTValue, value_type: &DynWinRTType) -> PyResult { + value.check_input("DynWinRTValue.box_reference()", InputSlot::Argument(0))?; dynwinrt::box_ireference(value.0.clone(), value_type.0.clone()) .map(DynWinRTValue::new) .map_err(map_dynwinrt_error) @@ -1711,6 +1721,15 @@ impl DynWinRTValue { self.0.is_null_object() } + /// Whether `release()` has run on this value, directly or through + /// `release_projected()` or a closing `projected_lifetime_scope()`. + /// + /// A released value is also `is_null()`; this tells it apart from a WinRT + /// null reference, which remains usable as a null input. + fn is_released(&self) -> bool { + matches!(self.1, Lifecycle::Released) + } + /// Release resources owned by this value and replace it with Null. /// /// This is idempotent so projected lifetime scopes can safely retry diff --git a/bindings/py/tests/test_error_messages.py b/bindings/py/tests/test_error_messages.py index cccc77c4..245e185b 100644 --- a/bindings/py/tests/test_error_messages.py +++ b/bindings/py/tests/test_error_messages.py @@ -10,6 +10,7 @@ import sys import textwrap import threading +from uuid import uuid4 import pytest @@ -18,6 +19,9 @@ RO_INIT_MULTITHREADED, RO_INIT_SINGLETHREADED, DynWinRTArray, + DynWinRTImplementation, + DynWinRTImplementationMethod, + DynWinRTInterfacePlan, DynWinRTMethodSig, DynWinRTStruct, DynWinRTType, @@ -42,6 +46,7 @@ IID_ISTRINGABLE = WinGUID.parse("96369F54-8EB6-48F0-ABCE-C1B211E627C3") IID_TEST_DELEGATE = WinGUID.parse("5A0F1C3E-7B24-4D69-8E1F-2C3B4A5D6E7F") IID_IPROPERTY_VALUE_STATICS = WinGUID.parse("629BDBC8-D932-4FF4-96B9-8D96C5C1E858") +PYTHON_EXCEPTION = -1594998779 # 0xA0EE4005 RPC_E_CHANGED_MODE = -2147417850 CO_E_NOTINITIALIZED = -2147221008 NOT_INITIALIZED_HINT = ( @@ -307,6 +312,73 @@ def test_unbox_object_distinguishes_released_values_from_null(): unbox_object(boxed) +def test_is_released_tells_released_values_from_winrt_null(): + null = DynWinRTValue.null_value() + assert null.is_null() and not null.is_released() + value = DynWinRTValue.from_i32(1) + assert not value.is_released() + value.release() + value.release() + assert value.is_null() and value.is_released() + + +def test_box_reference_rejects_released_values(): + with RoApartment(): + boxed = DynWinRTValue.box_reference(DynWinRTValue.from_i32(7), DynWinRTType.i32_type()) + assert not boxed.is_null() + boxed.release() + released = DynWinRTValue.from_i32(7) + released.release() + with pytest.raises( + RuntimeError, match=released_argument(0, "DynWinRTValue.box_reference()") + ): + DynWinRTValue.box_reference(released, DynWinRTType.i32_type()) + + +def test_implementation_callbacks_reject_released_outputs(monkeypatch): + objects = DynWinRTType.object() + iid = WinGUID.parse(str(uuid4())) + # GetPair(out Object first) -> Object: one out parameter, then the result. + signature = DynWinRTMethodSig().add_out(objects).add_out(objects) + typ = DynWinRTType.register_interface("Tests.ReleasedOutputs", iid).add_method( + "GetPair", signature + ) + plan = DynWinRTInterfacePlan.create( + "Tests.ReleasedOutputs", typ, [DynWinRTImplementationMethod("GetPair", 6, signature)] + ) + unraisable = [] + monkeypatch.setattr(sys, "unraisablehook", unraisable.append) + with RoApartment(): + live = DynWinRTValue.activation_factory("Windows.Foundation.Uri") + outputs = [live, DynWinRTValue.null_value()] + owner = DynWinRTImplementation.create([plan], lambda _interface, _slot, _args: outputs) + canonical = owner.to_value() + view = canonical.cast(iid) + canonical.release() + try: + first, result = typ.method(6).invoke_all(view, []) + assert first.identity_raw() == live.identity_raw() and result.is_null() + first.release() + + for position in (0, 1): + outputs = [live, DynWinRTValue.null_value()] + outputs[position] = _released_uri_value() + with pytest.raises(OSError) as caught: + typ.method(6).invoke_all(view, []) + assert caught.value.winerror == PYTHON_EXCEPTION + expected = released_input(f"output {position}", "implementation callback") + assert re.match(expected, str(unraisable.pop().exc_value)) + assert f"(output {position} of implementation callback)" in owner.take_error() + assert not unraisable + + outputs = [DynWinRTValue.null_value(), DynWinRTValue.null_value()] + assert all(value.is_null() for value in typ.method(6).invoke_all(view, [])) + finally: + view.release() + owner.dispose() + live.release() + + def test_delegate_invocation_rejects_released_receivers_and_arguments(): signature = DynWinRTMethodSig().add_in(DynWinRTType.object()) calls = [] diff --git a/tests/e2e/runners/implementation_py.py b/tests/e2e/runners/implementation_py.py index 6a7c5704..eb2dc2e0 100644 --- a/tests/e2e/runners/implementation_py.py +++ b/tests/e2e/runners/implementation_py.py @@ -1111,13 +1111,142 @@ def parse_double(self, text): dw.release_projected(view) +RELEASED = ( + r"has been released \(its projected_lifetime_scope\(\) exited, or " + r"release_projected\(\) / DynWinRTValue\.release\(\) was called\) and can no " + r"longer be used\." +) +INSPECTABLE_IID = "af86e2e0-b12d-4c6a-9c5a-d7aa65101e90" +PROPERTY_VALUE_NAMES = ( + "uint8", "int16", "uint16", "int32", "uint32", "int64", "uint64", "single", + "double", "char16", "boolean", "string", "guid", "date_time", "time_span", + "point", "size", "rect", +) + + +def released_references(g, dw, own): + """Released values stay released through generated implementation code.""" + + class Text: + def to_string(self): + return "released reference" + + text_owner = own(g.IStringable.implement(Text())) + live = text_owner.to_value() + + def released_value(): + value = text_owner.to_value() + value.release() + assert value.is_released() and value.is_null() + return value + + # A stored event handler invoked with a released sender. + callbacks = {} + + class Reference: + def get_capacity(self): + return 1 + + def add_closed(self, handler): + callbacks[len(callbacks) + 1] = handler + return g.EventRegistrationToken(value=len(callbacks)) + + def remove_closed(self, token): + callbacks.pop(token.value, None) + + def close(self): + pass + + reference = Reference() + reference_owner = own(g.IMemoryBufferReference.implement( + reference, g.IClosable.implementation(reference) + )) + sender = g.IMemoryBufferReference.from_implementation(reference_owner) + senders = [] + unsubscribe = sender.subscribe_closed(lambda value, _args: senders.append(value)) + handler = callbacks[1] + with dw.projected_lifetime_scope(): + scoped_sender = g.IMemoryBufferReference.from_value(sender._obj) + for released_sender in (released_value(), scoped_sender): + expect_error( + lambda: handler(released_sender, None), + r"\(argument 0 of delegate Invoke\(\)\) " + RELEASED, + ) + assert senders == [], "A released sender must not reach the native handler as null" + handler(None, None) + handler(dw.DynWinRTValue.null_value(), None) + assert senders == [None, None] + unsubscribe() + + # An element of an array result. + items = {"value": [live, None]} + methods = { + "get_type": not_implemented, + "get_is_numeric_scalar": not_implemented, + "get_inspectable_array": lambda self: items["value"], + } + for name in PROPERTY_VALUE_NAMES: + methods[f"get_{name}"] = methods[f"get_{name}_array"] = not_implemented + properties_owner = own(g.IPropertyValue.implement(type("Inspectables", (), methods)())) + properties = g.IPropertyValue.from_implementation(properties_owner) + result = properties.get_inspectable_array() + assert len(result) == 2 and result[1] is None + result[0].release() + items["value"] = [released_value(), live] + expect_hresult(properties.get_inspectable_array, PYTHON_CALLBACK_ERROR) + take_error( + properties_owner, + r"\(element 0 of DynWinRTArray\.from_object_values\(\)\) " + RELEASED, + ) + + # A direct result. + class Items: + item = None + get_size = index_of = first = not_implemented + + def get_at(self, index): + return self.item + + vector_handlers = Items() + vector_owner = own(g.IBindableVectorView.implement( + vector_handlers, g.IBindableIterable.implementation(vector_handlers) + )) + vector = g.IBindableVectorView.from_implementation(vector_owner) + assert vector.get_at(0) is None + vector_handlers.item = released_value() + expect_hresult(lambda: vector.get_at(0), PYTHON_CALLBACK_ERROR) + take_error(vector_owner, r"\(output 0 of implementation callback\) " + RELEASED) + vector_handlers.item = live + item = vector.get_at(0) + assert item.identity_raw() == live.identity_raw() + item.release() + + # Generated struct results store reference fields through the same helper. + runtime = importlib.import_module(f"{g.__name__}._runtime") + iid = dw.WinGUID.parse(INSPECTABLE_IID) + null = dw.DynWinRTValue.null_value() + assert runtime._implementation_reference(null, iid, "item") is null + released = released_value() + assert runtime._implementation_reference(released, iid, "item") is released + fields = dw.DynWinRTStruct.create(dw.DynWinRTType.struct_type( + "E2E.ReleasedReferenceField", [dw.DynWinRTType.object()] + )) + fields.set_object(0, runtime._implementation_reference(None, iid, "item")) + fields.set_object(0, runtime._implementation_reference(live, iid, "item")) + expect_error( + lambda: fields.set_object(0, runtime._implementation_reference(released, iid, "item")), + r"\(field 0 of DynWinRTStruct\.set_object\(\)\) " + RELEASED, + ) + live.release() + + CASES = { case.__name__: case for case in ( management_handle, property_views, background_task, multi_interface_lifetime, dispose_disconnects, reentrant_dispose, callback_error, async_handler_rejected, async_result_rejected, required_interfaces, memory_buffer_event, array_contracts, value_shapes, fill_array, fill_array_wrong_length, named_outputs, - nullable_reference_results, + nullable_reference_results, released_references, public_view_success_gc, public_view_failed_cast_gc, ) } diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index fa49c605..1821530c 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -580,8 +580,43 @@ async def run_check( f'wrapper IReference roundtrip returned {actual!r}, ' f'expected {check["compatibility_value"]!r}' ) - else: - cr['pass'] = True + return cr + + # A released wrapper is neither passed nor unboxed as a null + # reference. Generated struct IReference field setters share the + # module's unbox helper. + reason = ( + 'has been released (its projected_lifetime_scope() exited, or ' + 'release_projected() / DynWinRTValue.release() was called) and ' + 'can no longer be used.' + ) + released_box = factory(check['compatibility_value']) + released = reference_cls.from_value(getattr(released_box, '_obj', released_box)) + dw.release_projected(released) + unbox = importlib.import_module( + implementation_module_name(pkg_name, namespace, cls.__name__) + )._dynwinrt_unbox_reference + if unbox(None) is not None or unbox(reference) != check['compatibility_value']: + cr['error'] = 'IReference unbox helper changed a null or live value' + return cr + for label, use, expected in ( + ( + 'argument', + lambda: setattr(obj, member, released), + f'This WinRT object (argument 0 of invoke()) {reason}', + ), + ('unbox', lambda: unbox(released), f'This WinRT object {reason}'), + ): + try: + use() + except RuntimeError as error: + if str(error) != expected: + cr['error'] = f'{label}: expected {expected!r}, got {str(error)!r}' + return cr + else: + cr['error'] = f'{label}: a released IReference was accepted' + return cr + cr['pass'] = True elif kind == 'struct_roundtrip': struct_module = check.get( diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs index aead89dc..02a4fea5 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -183,7 +183,12 @@ def _dynwinrt_box_reference(value, value_type, wrap): def _dynwinrt_unbox_reference(value): raw = getattr(value, '_obj', None) if isinstance(raw, DynWinRTValue): - return None if raw.is_null() else value.value + # A released wrapper is not a null reference: reading its value raises. + # Runtimes without is_released() keep treating it as None. + is_released = getattr(raw, 'is_released', None) + if raw.is_null() and not (is_released is not None and is_released()): + return None + return value.value return value diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/implementation.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/implementation.rs index 72dbf32d..ec9dee46 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/implementation.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/implementation.rs @@ -62,7 +62,9 @@ def _implementation_reference(value, iid, label): raw = getattr(value, '_obj', value) if not isinstance(raw, DynWinRTValue): raise TypeError(f'{label}: expected a managed WinRT value or None') - return DynWinRTValue.null_value() if raw.is_null() else raw.cast(iid) + # Return a null or released value itself: native marshaling rejects only + # the released one, which a fresh null_value() would have hidden. + return raw if raw.is_null() else raw.cast(iid) def _implementation_sync(value, label): diff --git a/tools/dynwinrt-codegen/tests/python_released_implementation_test.rs b/tools/dynwinrt-codegen/tests/python_released_implementation_test.rs new file mode 100644 index 00000000..3152bd66 --- /dev/null +++ b/tools/dynwinrt-codegen/tests/python_released_implementation_test.rs @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +use dynwinrt_codegen::codegen::{python, python_stub}; +use dynwinrt_codegen::meta::{InterfaceMeta, MethodMeta, ParamDirection, ParamMeta}; +use dynwinrt_codegen::types::{FieldMeta, TypeMeta}; + +static NEXT: AtomicU64 = AtomicU64::new(0); + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!( + "python-released-implementation-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn python() -> PathBuf { + std::env::var_os("DYNWINRT_TEST_PYTHON") + .map(PathBuf::from) + .unwrap_or_else(|| { + let venv = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("bindings") + .join("py") + .join(".venv") + .join("Scripts") + .join("python.exe"); + if venv.is_file() { + venv + } else { + PathBuf::from("python") + } + }) +} + +fn runtime_available() -> bool { + let available = Command::new(python()) + .args([ + "-c", + "import dynwinrt; assert hasattr(dynwinrt.DynWinRTValue, 'is_released')", + ]) + .output() + .is_ok_and(|output| output.status.success()); + assert!( + available || std::env::var("DYNWINRT_REQUIRE_IMPLEMENTATION_RUNTIME").as_deref() != Ok("1"), + "the released implementation probe requires the current Python binding" + ); + if !available { + eprintln!("Skipping the released implementation probe; set DYNWINRT_TEST_PYTHON."); + } + available +} + +/// `Contoso.ISource` returns a struct with an Object field, and an Object +/// through an out parameter plus the result. +fn source() -> (InterfaceMeta, TypeMeta) { + let holder = TypeMeta::Struct { + namespace: "Contoso".into(), + name: "Holder".into(), + fields: vec![FieldMeta { + name: "Item".into(), + typ: TypeMeta::Object, + }], + }; + let interface = InterfaceMeta { + namespace: "Contoso".into(), + name: "ISource".into(), + iid: "3d1f0b7e-5c2a-4e8b-9f6d-1a2b3c4d5e6f".into(), + methods: vec![ + MethodMeta { + name: "GetHolder".into(), + vtable_index: 6, + return_type: Some(holder.clone()), + ..Default::default() + }, + MethodMeta { + name: "GetPair".into(), + vtable_index: 7, + params: vec![ParamMeta { + name: "first".into(), + typ: TypeMeta::Object, + direction: ParamDirection::Out, + }], + return_type: Some(TypeMeta::Object), + ..Default::default() + }, + ], + ..Default::default() + }; + (interface, holder) +} + +#[test] +fn generated_implementation_results_reject_released_references() { + let (interface, holder) = source(); + let context = python::PythonProjectionContext::packaged([ + interface.type_identity(), + holder.type_identity(), + ]) + .unwrap(); + let source_module = context.implementation_module_for_interface(&interface); + let holder_module = context.implementation_module_for_type(&holder); + let generated = python::generate_interface(&context, &interface); + // Reference results, including struct fields, share the helper that keeps + // a released value released instead of substituting a fresh null. + assert!( + generated.contains("s.set_object(0, _implementation_reference(value.item, "), + "{generated}" + ); + let runtime = python::generate_runtime_support_module(); + assert!( + runtime.contains(" return raw if raw.is_null() else raw.cast(iid)\n"), + "{runtime}" + ); + assert!( + !runtime.contains("null_value() if raw.is_null()"), + "{runtime}" + ); + if !runtime_available() { + return; + } + + let fixture = Fixture::new(); + let package = fixture.0.join("pyviews"); + fs::create_dir_all(&package).unwrap(); + for (name, source) in [ + ("__init__.py".to_string(), String::new()), + ("_runtime.py".to_string(), runtime), + ( + "_runtime.pyi".to_string(), + python_stub::generate_runtime_support_stub(), + ), + (format!("{source_module}.py"), generated), + ( + format!("{holder_module}.py"), + python::generate_struct(&context, &holder).unwrap(), + ), + ] { + fs::write(package.join(name), source).unwrap(); + } + fs::write( + fixture.0.join("probe.py"), + format!( + r#" +import importlib +import sys +import dynwinrt as dw + +ISource = importlib.import_module("pyviews.{source_module}").ISource +Holder = importlib.import_module("pyviews.{holder_module}").Holder +PYTHON_EXCEPTION = -1594998779 +RELEASED = ( + "has been released (its projected_lifetime_scope() exited, or release_projected() / " + "DynWinRTValue.release() was called) and can no longer be used." +) +state = {{"item": None, "pair": (None, None)}} + + +class Handlers: + def get_holder(self): + return Holder(item=state["item"]) + + def get_pair(self): + first, result = state["pair"] + return {{"first": first, "result": result}} + + +sys.unraisablehook = lambda _args: None +with dw.RoApartment(), dw.projected_lifetime_scope(): + live = dw.DynWinRTValue.activation_factory("Windows.Foundation.Uri") + + def released(): + value = live.cast(dw.WinGUID.parse("af86e2e0-b12d-4c6a-9c5a-d7aa65101e90")) + value.release() + return value + + with ISource.implement(Handlers()) as impl: + view = impl.value + # Real nulls and live values still cross every result path. An Object + # struct field reads back as a raw value, so a null one is is_null(). + assert view.get_holder().item.is_null() + state["item"] = live + assert not view.get_holder().item.is_null() + state["pair"] = (None, live) + view.get_pair() + state["pair"] = (live, None) + view.get_pair() + for call, update, slot in ( + (view.get_holder, {{"item": released()}}, "field 0 of DynWinRTStruct.set_object()"), + (view.get_pair, {{"item": None, "pair": (released(), None)}}, "output 0 of implementation callback"), + (view.get_pair, {{"pair": (None, released())}}, "output 1 of implementation callback"), + ): + state.update(update) + try: + call() + except OSError as error: + assert error.winerror == PYTHON_EXCEPTION, error + else: + raise AssertionError(f"{{slot}}: a released reference was returned") + message = impl.take_error() + assert f"This WinRT object ({{slot}}) {{RELEASED}}" in message, message + live.release() +print("released-implementation-ok") +"# + ), + ) + .unwrap(); + let output = Command::new(python()) + .args(["-B", "probe.py"]) + .current_dir(&fixture.0) + .output() + .unwrap(); + assert!( + output.status.success() + && String::from_utf8_lossy(&output.stdout).contains("released-implementation-ok"), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} From 134c50a23a36e52d1714373ef7601129e830e331 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 22:13:42 +0800 Subject: [PATCH 12/12] Run the released implementation probe in the e2e-runtime job python_released_implementation_test runs a generated implementation natively only when the Python runtime is installed. The test-rust job has no runtime, so CI ran only its static assertions. Add it to the e2e-runtime step that already runs implementation_naming_test against the freshly built wheel with DYNWINRT_REQUIRE_IMPLEMENTATION_RUNTIME=1. There, a missing runtime fails the test instead of skipping the probe. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6ce079b2..c4f1038a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -548,7 +548,7 @@ jobs: run: | $env:DYNWINRT_TEST_PYTHON = (Resolve-Path .\bindings\py\.venv\Scripts\python.exe).Path $env:DYNWINRT_REQUIRE_IMPLEMENTATION_RUNTIME = '1' - cargo test -p dynwinrt-codegen --test implementation_naming_test + cargo test -p dynwinrt-codegen --test implementation_naming_test --test python_released_implementation_test - name: Run E2E tests run: .\tests\e2e\e2e_test.ps1 -SkipBuild -Codegen $env:DYNWINRT_CODEGEN # This optional-SDK behavioral smoke is separate from generated coverage