From 5f09397e3e2ae8e67f19305bdcba0189b9a4e64c Mon Sep 17 00:00:00 2001 From: Albert Coroleu <67912747+acoroleu-tempus@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:40:56 +0800 Subject: [PATCH 1/3] fix: __del__ not freeing up native resources (#38) --- .gitignore | 1 + extism/extism.py | 29 ++++++++---- tests/test_extism.py | 104 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 75d21f6..fe51a48 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ poetry-installer-error-*.log docs/_build .DS_Store dist/ +.idea/ diff --git a/extism/extism.py b/extism/extism.py index d66eb5c..3eb92f7 100644 --- a/extism/extism.py +++ b/extism/extism.py @@ -270,10 +270,10 @@ def __init__(self, f: Function): _lib.extism_function_set_namespace(self.pointer, f.namespace.encode()) def __del__(self): - if not hasattr(self, "pointer"): + if not hasattr(self, "pointer") or self.pointer is None: return - if self.pointer is not None: - _lib.extism_function_free(self.pointer) + _lib.extism_function_free(self.pointer) + self.pointer = None def _map_arg(arg_name, xs) -> Tuple[ValType, Callable[[Any, Any], Any]]: @@ -507,7 +507,7 @@ def __init__( raise Error(msg.decode()) def __del__(self): - if not hasattr(self, "pointer"): + if not hasattr(self, "pointer") or self.pointer is None or self.pointer == -1: return _lib.extism_compiled_plugin_free(self.pointer) self.pointer = -1 @@ -551,10 +551,14 @@ def __init__( config: Optional[Any] = None, functions: Optional[List[Function]] = HOST_FN_REGISTRY, ): - if not isinstance(plugin, CompiledPlugin): - plugin = CompiledPlugin(plugin, wasi, functions) - - self.compiled_plugin = plugin + # Track if we created the CompiledPlugin (so we know to free it) + if isinstance(plugin, CompiledPlugin): + self._owns_compiled_plugin = False + self.compiled_plugin: Optional[CompiledPlugin] = plugin + else: + self._owns_compiled_plugin = True + self.compiled_plugin = CompiledPlugin(plugin, wasi, functions) + assert self.compiled_plugin is not None errmsg = _ffi.new("char**") self.plugin = _lib.extism_plugin_new_from_compiled( @@ -625,10 +629,17 @@ def call( return parse(buf) def __del__(self): - if not hasattr(self, "pointer"): + if not hasattr(self, "plugin") or self.plugin == -1: return _lib.extism_plugin_free(self.plugin) self.plugin = -1 + # Free the compiled plugin if we created it + if ( + getattr(self, "_owns_compiled_plugin", False) + and self.compiled_plugin is not None + ): + self.compiled_plugin.__del__() + self.compiled_plugin = None def __enter__(self): return self diff --git a/tests/test_extism.py b/tests/test_extism.py index c401a56..5f1454b 100644 --- a/tests/test_extism.py +++ b/tests/test_extism.py @@ -1,14 +1,17 @@ from collections import namedtuple -import unittest -import extism +import gc import hashlib import json +import pickle import time -from threading import Thread +import typing +import unittest from datetime import datetime, timedelta from os.path import join, dirname -import typing -import pickle +from threading import Thread + +import extism +from extism.extism import CompiledPlugin, _ExtismFunctionMetadata, TypeInferredFunction # A pickle-able object. @@ -47,6 +50,97 @@ def test_can_free_plugin(self): plugin = extism.Plugin(self._manifest()) del plugin + def test_plugin_del_frees_native_resources(self): + """Test that Plugin.__del__ properly frees native resources. + + This tests the fix for a bug where Plugin.__del__ checked for + 'self.pointer' instead of 'self.plugin', causing extism_plugin_free + to never be called and leading to memory leaks. + + This also tests that __del__ can be safely called multiple times + (via context manager exit and garbage collection) without causing + double-free errors. + """ + with extism.Plugin(self._manifest(), functions=[]) as plugin: + j = json.loads(plugin.call("count_vowels", "test")) + self.assertEqual(j["count"], 1) + # Plugin should own the compiled plugin it created + self.assertTrue(plugin._owns_compiled_plugin) + + # Verify plugin was freed after exiting context + self.assertEqual( + plugin.plugin, + -1, + "Expected plugin.plugin to be -1 after __del__, indicating extism_plugin_free was called", + ) + # Verify compiled plugin was also freed (since Plugin owned it) + self.assertIsNone( + plugin.compiled_plugin, + "Expected compiled_plugin to be None after __del__, indicating it was also freed", + ) + + def test_compiled_plugin_del_frees_native_resources(self): + """Test that CompiledPlugin.__del__ properly frees native resources. + + Unlike Plugin, CompiledPlugin has no context manager so __del__ is only + called once by garbage collection. This also tests that __del__ can be + safely called multiple times without causing double-free errors. + """ + compiled = CompiledPlugin(self._manifest(), functions=[]) + # Verify pointer exists before deletion + self.assertTrue(hasattr(compiled, "pointer")) + self.assertNotEqual(compiled.pointer, -1) + + # Create a plugin from compiled to ensure it works + plugin = extism.Plugin(compiled) + j = json.loads(plugin.call("count_vowels", "test")) + self.assertEqual(j["count"], 1) + + # Plugin should NOT own the compiled plugin (it was passed in) + self.assertFalse(plugin._owns_compiled_plugin) + + # Clean up plugin first + plugin.__del__() + self.assertEqual(plugin.plugin, -1) + + # Compiled plugin should NOT have been freed by Plugin.__del__ + self.assertNotEqual( + compiled.pointer, + -1, + "Expected compiled.pointer to NOT be -1 since Plugin didn't own it", + ) + + # Now clean up compiled plugin manually + compiled.__del__() + + # Verify compiled plugin was freed + self.assertEqual( + compiled.pointer, + -1, + "Expected compiled.pointer to be -1 after __del__, indicating extism_compiled_plugin_free was called", + ) + + def test_extism_function_metadata_del_frees_native_resources(self): + """Test that _ExtismFunctionMetadata.__del__ properly frees native resources.""" + + def test_host_fn(inp: str) -> str: + return inp + + func = TypeInferredFunction(None, "test_func", test_host_fn, []) + metadata = _ExtismFunctionMetadata(func) + + # Verify pointer exists before deletion + self.assertTrue(hasattr(metadata, "pointer")) + self.assertIsNotNone(metadata.pointer) + + metadata.__del__() + + # Verify function was freed (pointer set to None) + self.assertIsNone( + metadata.pointer, + "Expected metadata.pointer to be None after __del__, indicating extism_function_free was called", + ) + def test_errors_on_bad_manifest(self): self.assertRaises( extism.Error, lambda: extism.Plugin({"invalid_manifest": True}) From 2390197c052fbdef62b5ecadcfda8c640ad3d988 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Fri, 20 Feb 2026 14:13:07 -0800 Subject: [PATCH 2/3] fix: upgrade deprecated GitHub Actions to v4/v5 (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Upgrades `actions/upload-artifact` and `actions/download-artifact` from v3 to v4 (v3 is deprecated and now fails) - Bumps `actions/checkout` from v3 to v4 and `actions/setup-python` from v4 to v5 ## Test plan - [ ] Verify the `build.yaml` workflow runs successfully on push to main or tag push 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 --- .github/workflows/build.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 8af2568..f6b486d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -15,11 +15,11 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: extractions/setup-just@v1 - name: Install poetry run: pipx install poetry - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: '3.10' cache: 'poetry' @@ -40,7 +40,7 @@ jobs: run: | just docs - name: Upload docs artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: docs path: docs/_build @@ -48,7 +48,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: extractions/setup-just@v1 - name: Install poetry run: pipx install poetry @@ -64,7 +64,7 @@ jobs: fi <<<"$pyproject" >pyproject.toml sed -e 's/^version = "0.0.0.replaced-by-ci"/version = "'"$version"'"/g' - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: '3.10' cache: 'poetry' @@ -74,7 +74,7 @@ jobs: just build - name: upload artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: dist path: dist/* @@ -83,11 +83,11 @@ jobs: needs: [docs, build] runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: dist path: dist - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: docs path: docs From c4c03cb2162680ad60fea00ca7f1a32a60e690c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Mon, 23 Feb 2026 17:29:53 +0100 Subject: [PATCH 3/3] fix: float arguments in host function parameters (#39) --- extism/extism.py | 3 +-- tests/test_extism.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/extism/extism.py b/extism/extism.py index 3eb92f7..fcd3344 100644 --- a/extism/extism.py +++ b/extism/extism.py @@ -20,7 +20,6 @@ import functools import pickle - HOST_FN_REGISTRY: List[Any] = [] @@ -655,7 +654,7 @@ def _convert_value(x): return Val(ValType.I64, x.v.i64) elif x.t == 2: return Val(ValType.F32, x.v.f32) - elif x.y == 3: + elif x.t == 3: return Val(ValType.F64, x.v.f64) return None diff --git a/tests/test_extism.py b/tests/test_extism.py index 5f1454b..f7bb13c 100644 --- a/tests/test_extism.py +++ b/tests/test_extism.py @@ -308,6 +308,25 @@ def _infinite_loop_wasm(self): return read_test_wasm("loop.wasm") +ExtismVal = namedtuple("ExtismVal", ["t", "v"]) + + +class TestConvertValue(unittest.TestCase): + """Tests for the _convert_value helper that converts CFFI ExtismVal structs.""" + + def _make_extism_val(self, t, **kwargs): + """Create a mock ExtismVal with type tag `t` and value fields.""" + val_union = namedtuple("ValUnion", kwargs.keys())(**kwargs) + return ExtismVal(t=t, v=val_union) + + def test_convert_f64_value(self): + x = self._make_extism_val(3, f64=3.14) + result = extism.extism._convert_value(x) + self.assertIsNotNone(result, "_convert_value returned None for F64 input") + self.assertEqual(result.t, extism.ValType.F64) + self.assertAlmostEqual(result.value, 3.14) + + def read_test_wasm(p): path = join(dirname(__file__), "..", "wasm", p) with open(path, "rb") as wasm_file: