Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
9abcd0d
Retain on ObjCInstance creation, autorelease on __del__
samschott Nov 19, 2024
6605842
update tests
samschott Nov 19, 2024
931c352
add change note
samschott Nov 19, 2024
a618f2a
use autorelease instead of release in __del__
samschott Nov 20, 2024
b1bf61c
code formatting
samschott Nov 20, 2024
21f2e0b
update docs
samschott Nov 20, 2024
20ab8f9
add comment about autorelease vs release
samschott Nov 23, 2024
160c819
remove now unneeded cache staleness check
samschott Nov 23, 2024
ce9d78c
remove stale instance cache tests
samschott Nov 23, 2024
6d89330
update test_objcinstance_dealloc
samschott Nov 24, 2024
3bb7ccc
correct inline comment
samschott Nov 24, 2024
c0b091c
make returned_from_method private
samschott Nov 24, 2024
ab1f762
update ObjCInstance doc string
samschott Nov 24, 2024
544d694
updated docs
samschott Nov 24, 2024
b4a1624
update spellchecker
samschott Nov 24, 2024
f0edb5b
update change notes with migration instructions
samschott Nov 24, 2024
22396dc
Rephrase removal note
samschott Nov 25, 2024
7d51fde
remove unneeded space in doc string
samschott Nov 25, 2024
acfa546
change bugfix to feature note
samschott Nov 25, 2024
18e08cc
Fix incorrect inline comment
samschott Nov 25, 2024
532fbe0
trim trailing whitespace
samschott Nov 25, 2024
52e92c0
update test comment
samschott Nov 25, 2024
efed734
check that objects are not deallocated before end of autorelease pool
samschott Nov 25, 2024
ab8a895
merge object lifecycle tests
samschott Nov 25, 2024
30e4277
add a test case for copyWithZone returning the existing instance with…
samschott Nov 25, 2024
c3a4fe1
release additional refcounts by copy calls on the same ObjCInstance
samschott Nov 25, 2024
7bdc31f
rewrite the copy lifecycle test to use NSDictionary instead of a cust…
samschott Nov 26, 2024
460728b
prevent errors on ObjCInstance garbage collection when `send_message`…
samschott Nov 26, 2024
d9c0f62
switch copy lifecycle test to use NSString
samschott Nov 26, 2024
49d9381
remove unused import
samschott Nov 26, 2024
e0d7792
fix spelling mistake
samschott Nov 26, 2024
715912f
spelling updates
samschott Nov 26, 2024
20e45b6
spelling updates
samschott Nov 26, 2024
86b29a4
spelling updates
samschott Nov 26, 2024
944328d
black code formatting
samschott Nov 26, 2024
3b88aaa
rename test case to "immutable copy lifecycle"
samschott Nov 26, 2024
58d0276
improve inline docs
samschott Nov 27, 2024
84e3a9f
special handling for init
samschott Nov 27, 2024
ab46b9d
add tests for init object change
samschott Nov 28, 2024
2305122
implement proper method family detection
samschott Nov 28, 2024
2e4eccb
ensure partial methods are loaded from all superclasses
samschott Nov 28, 2024
2989540
remove unneeded whitespace
samschott Nov 29, 2024
0bc749c
improved release-on-cache-hit documentation
samschott Nov 29, 2024
04981e3
updated change notes
samschott Nov 29, 2024
54ed55c
add test for get_method_family
samschott Nov 29, 2024
c6096c2
remove loop that breaks early on method loading
samschott Nov 29, 2024
a28c901
make method loading slightly clearer
samschott Nov 29, 2024
1b306e2
extract and document method name to tuple logic
samschott Nov 29, 2024
849749e
fall back to full method usage if partial method lookup fails
samschott Nov 29, 2024
15593c1
update partial method cache after successful lookup
samschott Nov 29, 2024
39c548e
Revert "remove loop that breaks early on method loading"
samschott Nov 30, 2024
b78c41d
Revert "ensure partial methods are loaded from all superclasses"
samschott Nov 30, 2024
cb996ad
Reapply "ensure partial methods are loaded from all superclasses"
samschott Nov 30, 2024
6cf88a5
centralize logic for method family
samschott Dec 2, 2024
aa48b5d
update test description
samschott Dec 4, 2024
7fa9e71
update inline comments
samschott Dec 4, 2024
3be2190
fix method family detection
samschott Dec 4, 2024
dded73e
add test case for alloc without init
samschott Dec 4, 2024
199f807
race free interpreter shutdown handling
samschott Dec 4, 2024
d0961b4
black formatting
samschott Dec 4, 2024
a8e9193
more precise family determination to follow the exact rules laid out …
samschott Dec 4, 2024
296cf83
update tests for method family determination to check for non-lowerca…
samschott Dec 4, 2024
93300f3
fix typo in method_name_to_tuple doc string
samschott Dec 4, 2024
2be945b
more exhaustive refcount tests
samschott Dec 5, 2024
2387c02
remove duplicate code from test_objcinstance_returned_lifecycle
samschott Dec 5, 2024
51ba75b
Fix typo
mhsmith Dec 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions changes/256.feature.rst
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
Retain Objective-C objects when creating Python wrappers and release them when the
Python wrapped is garbage collected. This means that manual ``retain`` calls and
subsequent ``release`` or ``autorelease`` calls from Python are no longer needed with
very few exceptions such as:

1. When implementing methods like ``copy`` that are supposed to create an object, if
the returned object is not actually newly created.
2. When dealing with side effects of methods like ``init`` that may release an object
which is still referenced from Python. See for example
https://github.com/beeware/toga/issues/2468.
very few exceptions, for example when writing implementations of ``copy`` that return an
existing object.
159 changes: 113 additions & 46 deletions src/rubicon/objc/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,47 @@
# the Python objects are not destroyed if they are otherwise no Python references left.
_keep_alive_objects = {}

# Methods that return an object with is implicitly retained by the caller.
# See https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/MemoryMgmt/Articles/mmRules.html
_OWNERSHIP_METHOD_PREFIXES = (b"alloc", b"new", b"copy", b"mutableCopy")
# Methods that return an object which is implicitly retained by the caller.
# See https://clang.llvm.org/docs/AutomaticReferenceCounting.html#semantics-of-method-families.
_RETURNS_RETAINED_FAMILIES = {"init", "alloc", "new", "copy", "mutableCopy"}


def get_method_family(method_name: str) -> str:
"""Returns the method family from the method name. See
https://clang.llvm.org/docs/AutomaticReferenceCounting.html#method-families for
documentation on method families and corresponding selector names."""
method_name = method_name.lstrip("_").split(":")[0]
leading_lowercases = []
for c in method_name:
if c.isupper():
break
Comment thread
samschott marked this conversation as resolved.
Outdated
leading_lowercases.append(c)
return "".join(leading_lowercases)
Comment thread
samschott marked this conversation as resolved.
Outdated


def method_name_to_tuple(name: str) -> (str, tuple[str, ...]):
"""
Performs the following transformation:

"methodWithArg0:withArg1:withArg2" -> "methodWithArg0", ("", "withArg1", "withArg2")
Comment thread
samschott marked this conversation as resolved.
Outdated
"methodWithArg0:" -> "methodWithArg0", ("", )
"method" -> "method", ()

The first element of the returned tuple is the "base name" of the method. The second
element is a tuple with its argument names.
"""
# Selectors end with a colon if the method takes arguments.
if name.endswith(":"):
first, *rest, _ = name.split(":")
# Insert an empty string in order to indicate that the method
# takes a first argument as a positional argument.
rest.insert(0, "")
rest = tuple(rest)
else:
first = name
rest = ()

return first, rest


def encoding_from_annotation(f, offset=1):
Expand Down Expand Up @@ -214,6 +252,17 @@ def __call__(self, receiver, *args, convert_args=True, convert_result=True):
else:
converted_args = args

# Init methods consume their `self` argument (the receiver), see
# https://clang.llvm.org/docs/AutomaticReferenceCounting.html#semantics-of-init.
# To avoid segfaults on garbage collection if `init` does not return `self` but
Comment thread
mhsmith marked this conversation as resolved.
Outdated
# a different object or None, we issue an additional retain. This needs to be
# done before calling the method.
# Note that if `init` does return the same object, it will already be in our
# cache and balanced with a `release` on cache retrieval.
method_family = get_method_family(self.name.decode())
if method_family == "init":
send_message(receiver, "retain", restype=objc_id, argtypes=[])

result = send_message(
receiver,
self.selector,
Expand All @@ -226,8 +275,11 @@ def __call__(self, receiver, *args, convert_args=True, convert_result=True):
return result

# Convert result to python type if it is an instance or class pointer.
# Explicitly retain the instance on first handover to Python unless we
# received it from a method that gives us ownership already.
if self.restype is not None and issubclass(self.restype, objc_id):
result = ObjCInstance(result, _returned_from_method=self.name)
implicitly_owned = method_family in _RETURNS_RETAINED_FAMILIES
result = ObjCInstance(result, _implicitly_owned=implicitly_owned)

return result

Expand All @@ -239,7 +291,10 @@ def __init__(self, name_start):
super().__init__()

self.name_start = name_start
self.methods = {} # Initialized in ObjCClass._load_methods

# A dictionary mapping from a tuple of argument names to the full method name.
# Initialized in ObjCClass._load_methods
self.methods: dict[tuple[str, ...], str] = {}

def __repr__(self):
return f"{type(self).__qualname__}({self.name_start!r})"
Expand All @@ -257,21 +312,31 @@ def __call__(self, receiver, first_arg=_sentinel, **kwargs):
args.insert(0, first_arg)
rest = ("",) + order

# Try to use cached ObjCBoundMethod
try:
name = self.methods[rest]
meth = receiver.objc_class._cache_method(name)
return meth(receiver, *args)
except KeyError:
if first_arg is self._sentinel:
specified_sel = self.name_start
else:
specified_sel = f"{self.name_start}:{':'.join(kwargs.keys())}:"
raise ValueError(
f"Invalid selector {specified_sel}. Available selectors are: "
f"{', '.join(sel for sel in self.methods.values())}"
) from None
pass

# Reconstruct the full method name from arguments and look up actual method.
if first_arg is self._sentinel:
name = self.name_start
else:
name = f"{self.name_start}:{':'.join(kwargs.keys())}:"

meth = receiver.objc_class._cache_method(name)

return meth(receiver, *args)
if meth:
# Update methods cache and call method.
self.methods[rest] = name
return meth(receiver, *args)

raise ValueError(
f"Invalid selector {name}. Available selectors are: "
f"{', '.join(sel for sel in self.methods.values())}"
) from None


class ObjCBoundMethod:
Expand Down Expand Up @@ -772,8 +837,6 @@ class ObjCInstance:
# Refs #251.
_instance_lock = threading.RLock()

_python_refcount = 0

@property
def objc_class(self):
"""The Objective-C object's class, as an :class:`ObjCClass`."""
Expand Down Expand Up @@ -801,7 +864,7 @@ def _associated_attr_key_for_name(name):
return SEL(f"rubicon.objc.py_attr.{name}")

def __new__(
cls, object_ptr, _name=None, _bases=None, _ns=None, _returned_from_method=b""
cls, object_ptr, _name=None, _bases=None, _ns=None, _implicitly_owned=False
):
Comment thread
mhsmith marked this conversation as resolved.
"""The constructor accepts an :class:`~rubicon.objc.runtime.objc_id` or
anything that can be cast to one, such as a :class:`~ctypes.c_void_p`,
Expand Down Expand Up @@ -848,11 +911,18 @@ class or a metaclass, an instance of :class:`ObjCClass` or
# same object.
cached_obj = cls._cached_objects[object_ptr.value]

# If a cached instance was returned from a call such as `copy` or
# `mutableCopy`, we take ownership of an additional refcount. Release
# it here to prevent leaking memory, Python already owns a refcount from
# when the item was put in the cache.
if _returned_from_method.startswith(_OWNERSHIP_METHOD_PREFIXES):
# We can get a cache hit for methods that return an implicitly retained
# object. This is typically the case when:
#
# 1. A `copy` returns the original object if it is immutable. This is
# typically done for optimization. See
# https://developer.apple.com/documentation/foundation/nscopying.
# 2. An `init` call returns an object which we already own from a
# previous `alloc` call. See `init` handling in ObjCMethod. __call__.
#
# If the object is already in our cache, we end up owning more than one
# refcount. We release this additional refcount to prevent memory leaks.
if _implicitly_owned:
send_message(object_ptr, "release", restype=objc_id, argtypes=[])
Comment thread
samschott marked this conversation as resolved.

return cached_obj
Expand All @@ -861,7 +931,7 @@ class or a metaclass, an instance of :class:`ObjCClass` or

# Explicitly retain the instance on first handover to Python unless we
# received it from a method that gives us ownership already.
if not _returned_from_method.startswith(_OWNERSHIP_METHOD_PREFIXES):
if not _implicitly_owned:
send_message(object_ptr, "retain", restype=objc_id, argtypes=[])

# If the given pointer points to a class, return an ObjCClass instead (if we're not already creating one).
Expand Down Expand Up @@ -897,7 +967,8 @@ def __del__(self):
# Autorelease our reference on garbage collection of the Python wrapper. We use
# autorelease instead of release to allow ObjC to take ownership of an object when
# it is returned from a factory method.
send_message(self, "autorelease", restype=objc_id, argtypes=[])
if send_message and objc_id:
Comment thread
mhsmith marked this conversation as resolved.
Outdated
send_message(self, "autorelease", restype=objc_id, argtypes=[])

def __str__(self):
"""Get a human-readable representation of ``self``.
Expand Down Expand Up @@ -1544,44 +1615,40 @@ def _load_methods(self):
if self.methods_ptr is not None:
raise RuntimeError(f"{self}._load_methods cannot be called more than once")

methods_ptr_count = c_uint(0)
# Traverse superclasses and load methods.

@samschott samschott Nov 28, 2024

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not entirely sure why a full traversal of superclasses is required now but wasn't previously for tests on Python 3.12 + macOS 15 to pass. But I do believe that a class hierarchy traversal makes sense regardless.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is weird; I can only assume we're hitting some weird cache initialisation order thing (e.g., the test was previously initializing the super class before the class that was causing a problem). However, the solution here makes sense.

@samschott samschott Nov 29, 2024

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running the toga testbed suite reveals a similar issue for TogaSlider.setValue(value, animated=True) where the superclass method cannot be found. This is despite the fix here and could be related to the TogaSlider being a subclass defined in Python.

@samschott samschott Nov 29, 2024

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the direct method call TogaSlider.setValue_animated_(value, True) succeeds: https://github.com/beeware/toga/actions/runs/12089448592/job/33714837506?pr=2978

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've managed to fix that by allowing direct method lookup if there is no cached partial method, similar to what we already do for the old style syntax.

This is very likely a race condition somewhere, but the code around ObjCClass._load_methods, ObjCClass._cache_method and ObjCPartialMethod quite complex and hard to follow.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But all those changes to method lookup are making this PR a bit unwieldy. I've reverted some of the unneeded commits and am happy to split this off into an entirely different PR if that makes it easier.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the method lookup isn't strictly related to the memory retention issue, but I'm OK with the level of complexity it adds to this PR in the interest of addressing some issues that we know exist when this PR is used in Toga.

@samschott samschott Dec 2, 2024

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, something is still very fishy about my solution in this PR. _load_methods should have already been recursive, with the main difference that is was hard-stopping the recursion if one of the superclasses had already called _load_methods. This makes assumptions of the superclass chain not changing during init.

The new solution forces recursion to continue, but in a horribly hacky way. I do still want to find a more elegant solution here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I have had another look at all those recursive calls and I think that (1) I do understand better now what is going on and (2) have a more elegant solution than this PR to method loading fixes (https://github.com/samschott/rubicon-objc/tree/method-loading).

But this is indeed better reviewed in isolation, so I am happy to proceed with this PR as is and send out an independent PR for stylistic improvements and hopefully clearer code logic.

superclass = self.superclass

methods_ptr = libobjc.class_copyMethodList(self, byref(methods_ptr_count))

if self.superclass is not None:
if self.superclass.methods_ptr is None:
with self.superclass.cache_lock:
self.superclass._load_methods()
while superclass is not None:
if superclass.methods_ptr is None:
with superclass.cache_lock:
superclass._load_methods()

# Prime this class' partials list with a list from the superclass.
for first, superpartial in self.superclass.partial_methods.items():
for first, superpartial in superclass.partial_methods.items():
partial = ObjCPartialMethod(first)
self.partial_methods[first] = partial
partial.methods.update(superpartial.methods)

superclass = superclass.superclass

# Load methods for this class.
methods_ptr_count = c_uint(0)
methods_ptr = libobjc.class_copyMethodList(self, byref(methods_ptr_count))

for i in range(methods_ptr_count.value):
method = methods_ptr[i]
name = libobjc.method_getName(method).name.decode("utf-8")
self.instance_method_ptrs[name] = method

# Selectors end with a colon if the method takes arguments.
if name.endswith(":"):
first, *rest, _ = name.split(":")
# Insert an empty string in order to indicate that the method
# takes a first argument as a positional argument.
rest.insert(0, "")
rest = tuple(rest)
else:
first = name
rest = ()
base_name, argument_names = method_name_to_tuple(name)

try:
partial = self.partial_methods[first]
partial = self.partial_methods[base_name]
except KeyError:
partial = ObjCPartialMethod(first)
self.partial_methods[first] = partial
partial = ObjCPartialMethod(base_name)
self.partial_methods[base_name] = partial

partial.methods[rest] = name
partial.methods[argument_names] = name

# Set the list of methods for the class to the computed list.
self.methods_ptr = methods_ptr
Expand Down
Loading