Skip to content

Commit 5f09397

Browse files
fix: __del__ not freeing up native resources (#38)
1 parent 5b8da6e commit 5f09397

File tree

3 files changed

+120
-14
lines changed

3 files changed

+120
-14
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ poetry-installer-error-*.log
44
docs/_build
55
.DS_Store
66
dist/
7+
.idea/

extism/extism.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,10 @@ def __init__(self, f: Function):
270270
_lib.extism_function_set_namespace(self.pointer, f.namespace.encode())
271271

272272
def __del__(self):
273-
if not hasattr(self, "pointer"):
273+
if not hasattr(self, "pointer") or self.pointer is None:
274274
return
275-
if self.pointer is not None:
276-
_lib.extism_function_free(self.pointer)
275+
_lib.extism_function_free(self.pointer)
276+
self.pointer = None
277277

278278

279279
def _map_arg(arg_name, xs) -> Tuple[ValType, Callable[[Any, Any], Any]]:
@@ -507,7 +507,7 @@ def __init__(
507507
raise Error(msg.decode())
508508

509509
def __del__(self):
510-
if not hasattr(self, "pointer"):
510+
if not hasattr(self, "pointer") or self.pointer is None or self.pointer == -1:
511511
return
512512
_lib.extism_compiled_plugin_free(self.pointer)
513513
self.pointer = -1
@@ -551,10 +551,14 @@ def __init__(
551551
config: Optional[Any] = None,
552552
functions: Optional[List[Function]] = HOST_FN_REGISTRY,
553553
):
554-
if not isinstance(plugin, CompiledPlugin):
555-
plugin = CompiledPlugin(plugin, wasi, functions)
556-
557-
self.compiled_plugin = plugin
554+
# Track if we created the CompiledPlugin (so we know to free it)
555+
if isinstance(plugin, CompiledPlugin):
556+
self._owns_compiled_plugin = False
557+
self.compiled_plugin: Optional[CompiledPlugin] = plugin
558+
else:
559+
self._owns_compiled_plugin = True
560+
self.compiled_plugin = CompiledPlugin(plugin, wasi, functions)
561+
assert self.compiled_plugin is not None
558562
errmsg = _ffi.new("char**")
559563

560564
self.plugin = _lib.extism_plugin_new_from_compiled(
@@ -625,10 +629,17 @@ def call(
625629
return parse(buf)
626630

627631
def __del__(self):
628-
if not hasattr(self, "pointer"):
632+
if not hasattr(self, "plugin") or self.plugin == -1:
629633
return
630634
_lib.extism_plugin_free(self.plugin)
631635
self.plugin = -1
636+
# Free the compiled plugin if we created it
637+
if (
638+
getattr(self, "_owns_compiled_plugin", False)
639+
and self.compiled_plugin is not None
640+
):
641+
self.compiled_plugin.__del__()
642+
self.compiled_plugin = None
632643

633644
def __enter__(self):
634645
return self

tests/test_extism.py

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
from collections import namedtuple
2-
import unittest
3-
import extism
2+
import gc
43
import hashlib
54
import json
5+
import pickle
66
import time
7-
from threading import Thread
7+
import typing
8+
import unittest
89
from datetime import datetime, timedelta
910
from os.path import join, dirname
10-
import typing
11-
import pickle
11+
from threading import Thread
12+
13+
import extism
14+
from extism.extism import CompiledPlugin, _ExtismFunctionMetadata, TypeInferredFunction
1215

1316

1417
# A pickle-able object.
@@ -47,6 +50,97 @@ def test_can_free_plugin(self):
4750
plugin = extism.Plugin(self._manifest())
4851
del plugin
4952

53+
def test_plugin_del_frees_native_resources(self):
54+
"""Test that Plugin.__del__ properly frees native resources.
55+
56+
This tests the fix for a bug where Plugin.__del__ checked for
57+
'self.pointer' instead of 'self.plugin', causing extism_plugin_free
58+
to never be called and leading to memory leaks.
59+
60+
This also tests that __del__ can be safely called multiple times
61+
(via context manager exit and garbage collection) without causing
62+
double-free errors.
63+
"""
64+
with extism.Plugin(self._manifest(), functions=[]) as plugin:
65+
j = json.loads(plugin.call("count_vowels", "test"))
66+
self.assertEqual(j["count"], 1)
67+
# Plugin should own the compiled plugin it created
68+
self.assertTrue(plugin._owns_compiled_plugin)
69+
70+
# Verify plugin was freed after exiting context
71+
self.assertEqual(
72+
plugin.plugin,
73+
-1,
74+
"Expected plugin.plugin to be -1 after __del__, indicating extism_plugin_free was called",
75+
)
76+
# Verify compiled plugin was also freed (since Plugin owned it)
77+
self.assertIsNone(
78+
plugin.compiled_plugin,
79+
"Expected compiled_plugin to be None after __del__, indicating it was also freed",
80+
)
81+
82+
def test_compiled_plugin_del_frees_native_resources(self):
83+
"""Test that CompiledPlugin.__del__ properly frees native resources.
84+
85+
Unlike Plugin, CompiledPlugin has no context manager so __del__ is only
86+
called once by garbage collection. This also tests that __del__ can be
87+
safely called multiple times without causing double-free errors.
88+
"""
89+
compiled = CompiledPlugin(self._manifest(), functions=[])
90+
# Verify pointer exists before deletion
91+
self.assertTrue(hasattr(compiled, "pointer"))
92+
self.assertNotEqual(compiled.pointer, -1)
93+
94+
# Create a plugin from compiled to ensure it works
95+
plugin = extism.Plugin(compiled)
96+
j = json.loads(plugin.call("count_vowels", "test"))
97+
self.assertEqual(j["count"], 1)
98+
99+
# Plugin should NOT own the compiled plugin (it was passed in)
100+
self.assertFalse(plugin._owns_compiled_plugin)
101+
102+
# Clean up plugin first
103+
plugin.__del__()
104+
self.assertEqual(plugin.plugin, -1)
105+
106+
# Compiled plugin should NOT have been freed by Plugin.__del__
107+
self.assertNotEqual(
108+
compiled.pointer,
109+
-1,
110+
"Expected compiled.pointer to NOT be -1 since Plugin didn't own it",
111+
)
112+
113+
# Now clean up compiled plugin manually
114+
compiled.__del__()
115+
116+
# Verify compiled plugin was freed
117+
self.assertEqual(
118+
compiled.pointer,
119+
-1,
120+
"Expected compiled.pointer to be -1 after __del__, indicating extism_compiled_plugin_free was called",
121+
)
122+
123+
def test_extism_function_metadata_del_frees_native_resources(self):
124+
"""Test that _ExtismFunctionMetadata.__del__ properly frees native resources."""
125+
126+
def test_host_fn(inp: str) -> str:
127+
return inp
128+
129+
func = TypeInferredFunction(None, "test_func", test_host_fn, [])
130+
metadata = _ExtismFunctionMetadata(func)
131+
132+
# Verify pointer exists before deletion
133+
self.assertTrue(hasattr(metadata, "pointer"))
134+
self.assertIsNotNone(metadata.pointer)
135+
136+
metadata.__del__()
137+
138+
# Verify function was freed (pointer set to None)
139+
self.assertIsNone(
140+
metadata.pointer,
141+
"Expected metadata.pointer to be None after __del__, indicating extism_function_free was called",
142+
)
143+
50144
def test_errors_on_bad_manifest(self):
51145
self.assertRaises(
52146
extism.Error, lambda: extism.Plugin({"invalid_manifest": True})

0 commit comments

Comments
 (0)