Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updated memory management #543

Merged
merged 66 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions changes/256.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Retain Objective-C objects when creating Python wrappers and release them when the
Python wrapped is garbage collected.
70 changes: 15 additions & 55 deletions src/rubicon/objc/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,7 @@ def __call__(self, receiver, *args, convert_args=True, convert_result=True):

# Convert result to python type if it is an instance or class pointer.
if self.restype is not None and issubclass(self.restype, objc_id):
result = ObjCInstance(result)

# Mark for release if we acquire ownership of an object. Do not autorelease here because
# we might retain a Python reference while the Obj-C reference goes out of scope.
if self.name.startswith((b"alloc", b"new", b"copy", b"mutableCopy")):
result._needs_release = True
result = ObjCInstance(result, returned_from_method=self.name)

return result

Expand Down Expand Up @@ -783,29 +778,16 @@ def objc_class(self):
return super(ObjCInstance, type(self)).__getattribute__(self, "_objc_class")
except AttributeError:
# This assumes that objects never change their class after they are
# seen by Rubicon. There are two reasons why this may not be true:
# seen by Rubicon. This can occur because:
#
# 1. Objective-C runtime provides a function object_setClass that
# Objective-C runtime provides a function object_setClass that
# can change an object's class after creation, and some code
# manipulates objects' isa pointers directly (although the latter
# is no longer officially supported by Apple). This is not
# commonly done in practice, and even then it is usually only
# done during object creation/initialization, so it's basically
# safe to assume that an object's class will never change after
# it's been wrapped in an ObjCInstance.
# 2. If a memory address is freed by the Objective-C runtime, and
# then re-allocated by an object of a different type, but the
# Python ObjCInstance wrapper persists, Python will assume the
# object is still of the old type. If a new ObjCInstance wrapper
# for the same pointer is re-created, a check is performed to
# ensure the type hasn't changed; this problem only affects
# pre-existing Python wrappers. If this occurs, it probably
# indicates an issue with the retain count on the Python side (as
# the Objective-C runtime shouldn't be able to dispose of an
# object if Python still has a handle to it). If this *does*
# happen, it will manifest as objects appearing to be the wrong
# type, and/or objects having the wrong list of attributes
# available. Refs #249.
super(ObjCInstance, type(self)).__setattr__(
self, "_objc_class", ObjCClass(libobjc.object_getClass(self))
)
Expand All @@ -815,7 +797,9 @@ def objc_class(self):
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):
def __new__(
cls, object_ptr, _name=None, _bases=None, _ns=None, returned_from_method=b""
):
"""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`,
or an existing :class:`ObjCInstance`.
Expand Down Expand Up @@ -914,6 +898,13 @@ class or a metaclass, an instance of :class:`ObjCClass` or
except KeyError:
pass

# 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(
(b"alloc", b"new", b"copy", b"mutableCopy")
):
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).
if not issubclass(cls, ObjCClass) and object_isClass(object_ptr):
return ObjCClass(object_ptr)
Expand All @@ -932,7 +923,6 @@ class or a metaclass, an instance of :class:`ObjCClass` or
super(ObjCInstance, type(self)).__setattr__(
self, "_as_parameter_", object_ptr
)
super(ObjCInstance, type(self)).__setattr__(self, "_needs_release", False)
if isinstance(object_ptr, objc_block):
super(ObjCInstance, type(self)).__setattr__(
self, "block", ObjCBlock(object_ptr)
Expand All @@ -944,39 +934,9 @@ class or a metaclass, an instance of :class:`ObjCClass` or

return self

def release(self):
"""Manually decrement the reference count of the corresponding objc
object.

The objc object is sent a dealloc message when its reference
count reaches 0. Calling this method manually should not be
necessary, unless the object was explicitly ``retain``\\ed
before. Objects returned from ``.alloc().init...(...)`` and
similar calls are released automatically by Rubicon when the
corresponding Python object is deallocated.
"""
self._needs_release = False
send_message(self, "release", restype=objc_id, argtypes=[])

def autorelease(self):
"""Decrements the receiver’s reference count at the end of the current
autorelease pool block.

The objc object is sent a dealloc message when its reference
count reaches 0. If called, the object will not be released when
the Python object is deallocated.
"""
self._needs_release = False
result = send_message(self, "autorelease", restype=objc_id, argtypes=[])
return ObjCInstance(result)

def __del__(self):
"""Release the corresponding objc instance if we own it, i.e., if it
was returned by a method starting with :meth:`alloc`, :meth:`new`,
:meth:`copy`, or :meth:`mutableCopy` and it wasn't already explicitly
released by calling :meth:`release` or :meth:`autorelease`."""
if self._needs_release:
send_message(self, "release", restype=objc_id, argtypes=[])
"""Release the corresponding objc instance."""
send_message(self, "release", restype=objc_id, argtypes=[])

def __str__(self):
"""Get a human-readable representation of ``self``.
Expand Down
134 changes: 62 additions & 72 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import sys
import threading
import unittest
import uuid
import weakref
from ctypes import (
ArgumentError,
Expand Down Expand Up @@ -60,6 +61,10 @@
from . import OSX_VERSION, rubiconharness


class ObjcWeakref(NSObject):
weak_property = objc_property(weak=True)


class struct_int_sized(Structure):
_fields_ = [("x", c_char * 4)]

Expand Down Expand Up @@ -1859,94 +1864,79 @@ class TestO:

self.assertIsNone(wr_python_object())

def test_objcinstance_release_owned(self):
# Create an object which we own.
obj = NSObject.alloc().init()

# Check that it is marked for release.
self.assertTrue(obj._needs_release)

# Explicitly release the object.
obj.release()
def test_objcinstance_created_retained(self):
with autoreleasepool():
# Create an object which we don't own.
NSString = ObjCClass("NSString")
obj = NSString.stringWithString(str(uuid.uuid4()))

# Check that we no longer need to release it.
self.assertFalse(obj._needs_release)
self.assertEqual(obj.retainCount(), 1)

# Delete it and make sure that we don't segfault on garbage collection.
del obj
gc.collect()

def test_objcinstance_autorelease_owned(self):
def test_objcinstance_explicit_retained(self):
# Create an object which we own.
obj = NSObject.alloc().init()

# Check that it is marked for release.
self.assertTrue(obj._needs_release)
self.assertEqual(obj.retainCount(), 1)

# Explicitly release the object.
res = obj.autorelease()

# Check that autorelease call returned the object itself.
self.assertIs(obj, res)
def test_objcinstance_created_gc_released(self):
# Create an object which we own.
obj = NSObject.alloc().init()

# Check that we no longer need to release it.
self.assertFalse(obj._needs_release)
wr = ObjcWeakref.alloc().init()
wr.weak_property = obj

# Delete it and make sure that we don't segfault on garbage collection.
del obj
gc.collect()

def test_objcinstance_retain_release(self):
NSString = ObjCClass("NSString")

# Create an object which we don't own.
string = NSString.stringWithString("test")

# Check that it is not marked for release.
self.assertFalse(string._needs_release)
# Assert that the obj was deallocated.
self.assertIsNone(wr.weak_property)

# Explicitly retain the object.
res = string.retain()

# Check that autorelease call returned the object itself.
self.assertIs(string, res)

# Manually release the object.
string.release()

# Delete it and make sure that we don't segfault on garbage collection.
del string
gc.collect()

def test_objcinstance_dealloc(self):
class DeallocTester(NSObject):
attr0 = objc_property()
attr1 = objc_property(weak=True)

@objc_method
def dealloc(self):
self._did_dealloc = True

obj = DeallocTester.alloc().init()
obj.__dict__["_did_dealloc"] = False

attr0 = NSObject.alloc().init()
attr1 = NSObject.alloc().init()

obj.attr0 = attr0
obj.attr1 = attr1
def test_objcinstance_explicit_retained_gc_released(self):
with autoreleasepool():
# Create an object which we don't own.
NSString = ObjCClass("NSString")
obj = NSString.stringWithString(str(uuid.uuid4()))

self.assertEqual(attr0.retainCount(), 2)
self.assertEqual(attr1.retainCount(), 1)
wr = ObjcWeakref.alloc().init()
wr.weak_property = obj

# ObjC object will be deallocated, can only access Python attributes afterwards.
obj.release()
# Delete it and make sure that we don't segfault on garbage collection.
del obj
gc.collect()

self.assertTrue(obj._did_dealloc, "custom dealloc did not run")
self.assertEqual(
attr0.retainCount(), 1, "strong property value was not released"
)
self.assertEqual(attr1.retainCount(), 1, "weak property value was released")
# Assert that the obj was deallocated.
self.assertIsNone(wr.weak_property)

# def test_objcinstance_dealloc(self):
# class DeallocTester(NSObject):
# attr0 = objc_property()
# attr1 = objc_property(weak=True)
#
# @objc_method
# def dealloc(self):
# self._did_dealloc = True
#
# obj = DeallocTester.alloc().init()
# obj.__dict__["_did_dealloc"] = False
#
# attr0 = NSObject.alloc().init()
# attr1 = NSObject.alloc().init()
#
# obj.attr0 = attr0
# obj.attr1 = attr1
#
# self.assertEqual(attr0.retainCount(), 2)
# self.assertEqual(attr1.retainCount(), 1)
#
# # ObjC object will be deallocated, can only access Python attributes afterwards.
# obj.release()
#
# self.assertTrue(obj._did_dealloc, "custom dealloc did not run")
# self.assertEqual(
# attr0.retainCount(), 1, "strong property value was not released"
# )
# self.assertEqual(attr1.retainCount(), 1, "weak property value was released")

def test_partial_with_override(self):
"""If one method in a partial is overridden, that doesn't impact lookup of other partial targets"""
Expand Down