From 152f53ea2740b0dd84a86c9e881e26539b59cd07 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:53:05 +0900 Subject: [PATCH 1/2] Test: Always run the TypedPython leak test now that #98 is fixed --- .../commonMain/kotlin/fixture/app/TypedPythonBridge.kt | 6 ++++-- .../kotlin/fixture/app/TypedPythonKotlinCallTest.kt | 8 -------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/TypedPythonBridge.kt b/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/TypedPythonBridge.kt index 1a856b27d..5b621546f 100644 --- a/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/TypedPythonBridge.kt +++ b/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/TypedPythonBridge.kt @@ -52,8 +52,10 @@ fun tpCheck(value: Double, limit: Double): Double { * wrapper unreachable -- so the token's `sys.getrefcount` is only meaningful after a JVM collection * and the cleaner have run (`TypedPythonKotlinCallTest.settledCounts`). * - * Deliberately **not** closed here: closing a `PyObject` argument inside an upcall releases its - * reference twice and crashed the test JVM (`Py_XDECREF` on the cleaner thread) -- binder issue #98. + * Not closed here, so the test also covers the cleaner path. Closing it would be equally correct: + * `close()` gives back the wrapper's one reference and the cleaner then has nothing left to release + * (`closeArgument`, `PyObjectArgumentReleaseTest`). The JVM crash once blamed on that (issue #98) came + * from a test wrapping fake addresses as owned objects, not from the binder. */ fun tpTouch(token: PyObject?): Long { TpBridgeLedger.touches += 1 diff --git a/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/TypedPythonKotlinCallTest.kt b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/TypedPythonKotlinCallTest.kt index a1d4eec96..805106f9b 100644 --- a/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/TypedPythonKotlinCallTest.kt +++ b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/TypedPythonKotlinCallTest.kt @@ -229,14 +229,6 @@ class TypedPythonKotlinCallTest { */ @Test fun tenThousandCompiledCallsLeakNoReferenceAndNoKotlinHandle() { - // Blocked by python-multiplatform#98: when the cleaner releases the wrappers that 10,000 - // upcalls leave behind, the binder releases one reference twice and the JVM dies (SIGSEGV in - // Py_XDECREF on Cleaner-0) -- on the interpreted control as much as on the compiled loop. - // Re-enable with -Dtypedpython.leakTest=true once #98 is fixed. - assumeTrue( - "blocked by python-multiplatform#98 (binder double release on the cleaner path)", - System.getProperty("typedpython.leakTest") == "true", - ) Python3.exec( """ import gc, sys, sysconfig From b0d93ea5fe9e796a06e5128a55951a6ef3e5840e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:57:12 +0900 Subject: [PATCH 2/2] CI: Run the TypedPython app fixture and fail when its tests skip --- .github/workflows/typedpython.yml | 49 +++++++++++++++++++++++++++++-- ksp-fixtures/app/build.gradle.kts | 7 +++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/workflows/typedpython.yml b/.github/workflows/typedpython.yml index 762b12fe2..444ebcd43 100644 --- a/.github/workflows/typedpython.yml +++ b/.github/workflows/typedpython.yml @@ -5,10 +5,10 @@ name: TypedPython CI on: push: branches: [ "develop", "main" ] - paths: [ "python-multiplatform-ksp/**", ".github/workflows/typedpython.yml" ] + paths: [ "python-multiplatform-ksp/**", "ksp-fixtures/app/**", ".github/workflows/typedpython.yml" ] pull_request: branches: [ "develop", "main" ] - paths: [ "python-multiplatform-ksp/**", ".github/workflows/typedpython.yml" ] + paths: [ "python-multiplatform-ksp/**", "ksp-fixtures/app/**", ".github/workflows/typedpython.yml" ] jobs: test: @@ -75,3 +75,48 @@ jobs: env $(.venv/bin/python tools/sanitizers.py env) \ .venv/bin/python -m pytest -q -m "not measurement" \ src/test/python/test_cgen.py src/test/python/test_runtime.py src/test/python/test_pipeline.py + + app-fixture: + # #45: a TypedPython-compiled module calling Kotlin through the upcall table, in the app + # fixture's desktop JVM. buildTypedPythonBridge needs a host Python with pyrefly; without one the + # four TypedPythonKotlinCallTest tests skip. Locally that is allowed; here a skip fails the job. + # macOS only: elsewhere the JVM does not load libpython with global symbols, so the + # dynamic_lookup extension cannot resolve the C API (ksp-fixtures/app/build.gradle.kts). + name: TypedPython from Kotlin, app fixture (macos-latest) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + cache: 'gradle' + + - uses: astral-sh/setup-uv@v5 + + - name: Create the TypedPython host environment + working-directory: python-multiplatform-ksp + run: | + uv venv .venv --python 3.13 + uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest + + - name: App fixture tests + run: | + rm -rf ksp-fixtures/app/build/test-results/desktopTest + ./gradlew :ksp-fixtures:app:desktopTest --rerun --console=plain \ + -PtypedpythonPython="$PWD/python-multiplatform-ksp/.venv/bin/python" + + - name: The TypedPython tests ran (none skipped) + run: | + python3 - <<'PY' + import glob, sys, xml.etree.ElementTree as ET + cases = [c for p in glob.glob("ksp-fixtures/app/build/test-results/desktopTest/*.xml") + for c in ET.parse(p).getroot().iter("testcase") + if c.get("classname") == "fixture.app.TypedPythonKotlinCallTest"] + skipped = [c.get("name") for c in cases if c.find("skipped") is not None] + print(f"TypedPythonKotlinCallTest: {len(cases)} test(s), {len(skipped)} skipped") + if not cases or skipped: + print("skipped: " + ", ".join(skipped) if skipped else "no TypedPythonKotlinCallTest results") + sys.exit(1) + PY diff --git a/ksp-fixtures/app/build.gradle.kts b/ksp-fixtures/app/build.gradle.kts index fd3257b5f..8f136bb14 100644 --- a/ksp-fixtures/app/build.gradle.kts +++ b/ksp-fixtures/app/build.gradle.kts @@ -156,6 +156,13 @@ val buildTypedPythonBridge by tasks.registering(Exec::class) { tasks.named("desktopTest") { dependsOn(buildTypedPythonBridge) + // The tests import tp_kotlin_bridge.py from src/; without this the interpreter leaves a + // __pycache__ in the source tree. + environment("PYTHONDONTWRITEBYTECODE", "1") + val skipReason = typedPythonSkipReason + doFirst { + if (skipReason != null) logger.warn("TypedPython bridge tests will be SKIPPED: $skipReason") + } if (typedPythonSkipReason == null) { inputs.dir(typedPythonBridgeDir).withPropertyName("typedPythonBridge") systemProperty(