|
1 | 1 | from collections import namedtuple |
2 | | -import unittest |
3 | | -import extism |
| 2 | +import gc |
4 | 3 | import hashlib |
5 | 4 | import json |
| 5 | +import pickle |
6 | 6 | import time |
7 | | -from threading import Thread |
| 7 | +import typing |
| 8 | +import unittest |
8 | 9 | from datetime import datetime, timedelta |
9 | 10 | 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 |
12 | 15 |
|
13 | 16 |
|
14 | 17 | # A pickle-able object. |
@@ -47,6 +50,97 @@ def test_can_free_plugin(self): |
47 | 50 | plugin = extism.Plugin(self._manifest()) |
48 | 51 | del plugin |
49 | 52 |
|
| 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 | + |
50 | 144 | def test_errors_on_bad_manifest(self): |
51 | 145 | self.assertRaises( |
52 | 146 | extism.Error, lambda: extism.Plugin({"invalid_manifest": True}) |
|
0 commit comments