This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 7b399377 [hotfix][runtime][python] Close every component when an 
earlier close fails (#987)
7b399377 is described below

commit 7b3993774802e96e3b26c56e0ed2cbd3ff9ef306
Author: Yifan Chen <[email protected]>
AuthorDate: Thu Aug 27 20:08:32 2026 -0700

    [hotfix][runtime][python] Close every component when an earlier close fails 
(#987)
    
    Generated-by: Claude Code 2.1.227 (Claude Opus 5)
    Co-Authored-By: Claude Opus 5 <[email protected]>
---
 python/flink_agents/runtime/resource_cache.py      |  58 ++++++-
 .../runtime/tests/test_resource_cache_close.py     | 130 ++++++++++++++
 .../apache/flink/agents/runtime/ResourceCache.java |  31 ++--
 .../runtime/operator/ActionExecutionOperator.java  |  44 +++--
 .../runtime/operator/ActionTaskContextManager.java |  21 ++-
 .../runtime/operator/PythonBridgeManager.java      |  30 +++-
 .../runtime/python/utils/PythonActionExecutor.java |  37 ++--
 .../flink/agents/runtime/skill/SkillManager.java   |  22 +--
 .../flink/agents/runtime/ResourceCacheTest.java    | 193 +++++++++++++++++++++
 .../operator/ActionExecutionOperatorTest.java      | 175 +++++++++++++++++++
 .../operator/ActionTaskContextManagerTest.java     |  61 +++++++
 .../runtime/operator/PythonBridgeManagerTest.java  | 109 ++++++++++++
 .../python/utils/PythonActionExecutorTest.java     |  85 +++++++++
 .../agents/runtime/skill/SkillManagerTest.java     |  45 ++++-
 14 files changed, 979 insertions(+), 62 deletions(-)

diff --git a/python/flink_agents/runtime/resource_cache.py 
b/python/flink_agents/runtime/resource_cache.py
index 3f98a8b7..9c96636b 100644
--- a/python/flink_agents/runtime/resource_cache.py
+++ b/python/flink_agents/runtime/resource_cache.py
@@ -15,6 +15,8 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
+import logging
+from collections.abc import Callable
 from typing import Any, Dict
 
 from flink_agents.api.resource import Resource, ResourceType
@@ -24,6 +26,38 @@ from flink_agents.plan.resource_provider import 
JavaResourceProvider, ResourcePr
 from flink_agents.plan.tools.function_tool import FunctionTool
 from flink_agents.runtime.resource_context import ResourceContextImpl
 
+_LOG = logging.getLogger(__name__)
+
+
+def _failure_of(close: Callable[[], None]) -> Exception | None:
+    """Run ``close``, returning any failure instead of raising it.
+
+    Keeps the caller's cleanup loop free of a ``try`` block so one bad 
component
+    cannot end the iteration.
+    """
+    try:
+        close()
+    except Exception as e:
+        return e
+    return None
+
+
+def _first_or_logged(
+    failure: Exception | None, previous: Exception | None, what: str
+) -> Exception | None:
+    """Keep the first failure and log any later one.
+
+    The Python analogue of Flink's ``ExceptionUtils.firstOrSuppressed``. Later
+    failures are logged rather than attached, because ``ExceptionGroup`` 
requires
+    3.11 and this package supports 3.10.
+    """
+    if failure is None:
+        return previous
+    if previous is None:
+        return failure
+    _LOG.warning("Suppressed failure closing %s.", what, exc_info=failure)
+    return previous
+
 
 class ResourceCache:
     """Lazily resolves and caches Resource instances from ResourceProviders.
@@ -100,9 +134,29 @@ class ResourceCache:
         cached ``SkillManager`` (releasing materialized skill temp dirs). This
         is what releases skill resources on operator close, including Flink
         failover when the JVM stays up.
+
+        Every resource is closed even when an earlier one fails, so a single 
bad
+        resource cannot strand the ones behind it, the cache clear, or the
+        resource context. The first failure is re-raised; later ones are 
logged.
+
+        This mirrors the Java ``ResourceCache.close()`` contract. Two 
differences
+        are deliberate. Java catches ``Throwable`` to keep a non-``Exception``
+        failure from stopping the loop; the Python equivalent is ``Exception``,
+        since it already covers what Java calls ``Error`` (``MemoryError`` and
+        friends) while ``BaseException`` would also swallow 
``KeyboardInterrupt``
+        and ``SystemExit``, which cleanup must not do. And Java attaches later
+        failures with ``addSuppressed``; ``ExceptionGroup`` needs 3.11 and this
+        package supports 3.10, so later failures are logged instead of 
attached.
         """
+        first_failure: Exception | None = None
         for typed in self._cache.values():
             for resource in typed.values():
-                resource.close()
+                first_failure = _first_or_logged(
+                    _failure_of(resource.close), first_failure, "resource"
+                )
         self._cache.clear()
-        self._resource_context.close()
+        first_failure = _first_or_logged(
+            _failure_of(self._resource_context.close), first_failure, 
"resource context"
+        )
+        if first_failure is not None:
+            raise first_failure
diff --git a/python/flink_agents/runtime/tests/test_resource_cache_close.py 
b/python/flink_agents/runtime/tests/test_resource_cache_close.py
new file mode 100644
index 00000000..61332e2c
--- /dev/null
+++ b/python/flink_agents/runtime/tests/test_resource_cache_close.py
@@ -0,0 +1,130 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+#################################################################################
+"""Close-path contract tests for the Python ResourceCache.
+
+These mirror the Java ``ResourceCacheTest`` close tests: a failing resource 
must
+not strand the resources behind it, the cache clear, or the resource context.
+"""
+
+import pytest
+
+from flink_agents.api.resource import Resource, ResourceType
+from flink_agents.runtime.resource_cache import ResourceCache
+
+
+class RecordingResource(Resource):
+    """Records whether ``close()`` ran, and optionally fails it."""
+
+    closed: bool = False
+    failure: Exception | None = None
+
+    @classmethod
+    def resource_type(cls) -> ResourceType:
+        return ResourceType.TOOL
+
+    def close(self) -> None:
+        """Record the call, then fail if this resource was configured to."""
+        self.closed = True
+        if self.failure is not None:
+            raise self.failure
+
+
+class RecordingContext:
+    """Stands in for the resource context so its close is observable."""
+
+    def __init__(self) -> None:
+        self.closed = False
+
+    def close(self) -> None:
+        """Record that the cache reached the resource context."""
+        self.closed = True
+
+
+def _cache_with(*resources: RecordingResource) -> tuple[ResourceCache, 
RecordingContext]:
+    cache = ResourceCache({})
+    context = RecordingContext()
+    cache._resource_context = context
+    for i, resource in enumerate(resources):
+        cache._cache.setdefault(ResourceType.TOOL, {})[f"r{i}"] = resource
+    return cache, context
+
+
+def test_close_closes_every_resource_when_an_earlier_one_fails() -> None:
+    """A failing resource must not strand the ones behind it.
+
+    The cache clear and the resource context close are on the same straight
+    line behind the failure, so both are asserted rather than assumed.
+    """
+    failure = RuntimeError("resource close failed")
+    failing = RecordingResource(failure=failure)
+    surviving = RecordingResource()
+    cache, context = _cache_with(failing, surviving)
+
+    with pytest.raises(RuntimeError) as excinfo:
+        cache.close()
+
+    # The failure reaches the caller unchanged in identity, not wrapped.
+    assert excinfo.value is failure
+    assert failing.closed
+    assert surviving.closed
+    assert cache._cache == {}
+    assert context.closed
+
+
+def test_close_reports_first_failure_when_several_fail() -> None:
+    """The first failure is the one raised; later ones do not replace it."""
+    first = RuntimeError("first")
+    second = RuntimeError("second")
+    cache, context = _cache_with(
+        RecordingResource(failure=first), RecordingResource(failure=second)
+    )
+
+    with pytest.raises(RuntimeError) as excinfo:
+        cache.close()
+
+    assert excinfo.value is first
+    assert context.closed
+
+
+def test_close_reports_resource_context_failure() -> None:
+    """A resource context failure surfaces when no resource failed before 
it."""
+    cache, context = _cache_with(RecordingResource())
+    failure = RuntimeError("resource context close failed")
+
+    def failing_close() -> None:
+        context.closed = True
+        raise failure
+
+    context.close = failing_close  # type: ignore[method-assign]
+
+    with pytest.raises(RuntimeError) as excinfo:
+        cache.close()
+
+    assert excinfo.value is failure
+
+
+def test_close_returns_normally_when_nothing_fails() -> None:
+    """The healthy path stays a plain no-raise close."""
+    surviving = RecordingResource()
+    cache, context = _cache_with(surviving)
+
+    cache.close()
+
+    assert surviving.closed
+    assert cache._cache == {}
+    assert context.closed
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/ResourceCache.java 
b/runtime/src/main/java/org/apache/flink/agents/runtime/ResourceCache.java
index e3584ac2..cd84da8c 100644
--- a/runtime/src/main/java/org/apache/flink/agents/runtime/ResourceCache.java
+++ b/runtime/src/main/java/org/apache/flink/agents/runtime/ResourceCache.java
@@ -25,6 +25,7 @@ import 
org.apache.flink.agents.plan.resourceprovider.PythonResourceProvider;
 import org.apache.flink.agents.plan.resourceprovider.ResourceProvider;
 import org.apache.flink.agents.plan.tools.FunctionTool;
 import org.apache.flink.agents.runtime.resource.ResourceContextImpl;
+import org.apache.flink.util.ExceptionUtils;
 
 import java.util.HashMap;
 import java.util.Map;
@@ -158,32 +159,32 @@ public class ResourceCache implements AutoCloseable {
 
     @Override
     public void close() throws Exception {
-        Exception firstException = null;
+        // Close every cached resource, then the resource context, even when 
an earlier close
+        // fails. The first failure is rethrown with the later ones suppressed.
+        //
+        // The ladders catch Throwable, not Exception: 
ActionExecutionOperator.close() closes this
+        // cache before the Python interpreter because cached resources may 
hold Python references,
+        // so a non-Exception Throwable escaping here would leave the 
remaining resources open
+        // while the interpreter behind them is torn down anyway. 
ExceptionUtils.rethrowException
+        // passes Error and Exception through unchanged, so the caller still 
sees the original.
+        Throwable firstFailure = null;
         for (Map<String, Resource> resources : cache.values()) {
             for (Resource resource : resources.values()) {
                 try {
                     resource.close();
-                } catch (Exception e) {
-                    if (firstException == null) {
-                        firstException = e;
-                    } else {
-                        firstException.addSuppressed(e);
-                    }
+                } catch (Throwable t) {
+                    firstFailure = ExceptionUtils.firstOrSuppressed(t, 
firstFailure);
                 }
             }
         }
         cache.clear();
         try {
             resourceContext.close();
-        } catch (Exception e) {
-            if (firstException == null) {
-                firstException = e;
-            } else {
-                firstException.addSuppressed(e);
-            }
+        } catch (Throwable t) {
+            firstFailure = ExceptionUtils.firstOrSuppressed(t, firstFailure);
         }
-        if (firstException != null) {
-            throw firstException;
+        if (firstFailure != null) {
+            ExceptionUtils.rethrowException(firstFailure);
         }
     }
 }
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
index 18931ee9..f82d8047 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
@@ -591,24 +591,38 @@ public class ActionExecutionOperator<IN, OUT> extends 
AbstractStreamOperator<OUT
 
     @Override
     public void close() throws Exception {
-        // Must close before pythonInterpreter since cached resources may hold 
Python references.
-        if (resourceCache != null) {
-            resourceCache.close();
-        }
-        if (contextManager != null) {
-            contextManager.close();
-        }
-        if (pythonBridge != null) {
-            pythonBridge.close();
-        }
-        if (eventLogWriter != null) {
-            eventLogWriter.close();
+        // Close every component even when an earlier one fails, so a failing 
close cannot leak
+        // the components behind it or skip super.close(). The first failure 
is rethrown with
+        // the later ones suppressed. Order is preserved: the resource cache 
must close before
+        // pythonInterpreter since cached resources may hold Python references.
+        //
+        // The ladder catches Throwable, not Exception, and IOUtils.closeAll 
is deliberately not
+        // used: both stop at the first non-Exception Throwable without 
closing what follows,
+        // which is the very leak this method has to avoid.
+        Throwable firstFailure = null;
+        for (AutoCloseable closeable :
+                new AutoCloseable[] {
+                    resourceCache, contextManager, pythonBridge, 
eventLogWriter, durableExecManager
+                }) {
+            if (closeable == null) {
+                continue;
+            }
+            try {
+                closeable.close();
+            } catch (Throwable t) {
+                firstFailure = ExceptionUtils.firstOrSuppressed(t, 
firstFailure);
+            }
         }
-        if (durableExecManager != null) {
-            durableExecManager.close();
+
+        try {
+            super.close();
+        } catch (Throwable t) {
+            firstFailure = ExceptionUtils.firstOrSuppressed(t, firstFailure);
         }
 
-        super.close();
+        if (firstFailure != null) {
+            ExceptionUtils.rethrowException(firstFailure);
+        }
     }
 
     @Override
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionTaskContextManager.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionTaskContextManager.java
index 0fd0caa6..6da5592e 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionTaskContextManager.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionTaskContextManager.java
@@ -35,6 +35,7 @@ import 
org.apache.flink.agents.runtime.python.context.PythonRunnerContextImpl;
 import org.apache.flink.agents.runtime.trace.ExecutionEventSink;
 import org.apache.flink.agents.runtime.trace.ReportedExecutionKey;
 import org.apache.flink.api.common.state.MapState;
+import org.apache.flink.util.ExceptionUtils;
 
 import javax.annotation.Nullable;
 
@@ -350,15 +351,33 @@ class ActionTaskContextManager implements AutoCloseable {
 
     @Override
     public void close() throws Exception {
+        // Close the continuation executor even when the runner context fails 
to close. The first
+        // failure is rethrown with the later one suppressed.
+        //
+        // The ladder catches Throwable, not Exception, so a non-Exception 
Throwable from the
+        // runner context cannot strand the executor's thread pool. Neither 
type implements
+        // AutoCloseable, so the aggregation is spelled out rather than 
delegated. Both rungs go
+        // through firstOrSuppressed even though the first one cannot yet have 
a previous failure,
+        // so that a close inserted above it later suppresses rather than 
overwrites.
+        Throwable firstFailure = null;
         if (runnerContext != null) {
             try {
                 runnerContext.close();
+            } catch (Throwable t) {
+                firstFailure = ExceptionUtils.firstOrSuppressed(t, 
firstFailure);
             } finally {
                 runnerContext = null;
             }
         }
         if (continuationActionExecutor != null) {
-            continuationActionExecutor.close();
+            try {
+                continuationActionExecutor.close();
+            } catch (Throwable t) {
+                firstFailure = ExceptionUtils.firstOrSuppressed(t, 
firstFailure);
+            }
+        }
+        if (firstFailure != null) {
+            ExceptionUtils.rethrowException(firstFailure);
         }
     }
 }
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/PythonBridgeManager.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/PythonBridgeManager.java
index ae3bedb3..3c4127ac 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/PythonBridgeManager.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/PythonBridgeManager.java
@@ -36,6 +36,7 @@ import 
org.apache.flink.agents.runtime.python.utils.PythonResourceAdapterImpl;
 import org.apache.flink.api.common.ExecutionConfig;
 import org.apache.flink.api.common.JobID;
 import org.apache.flink.python.env.PythonDependencyInfo;
+import org.apache.flink.util.ExceptionUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import pemja.core.PythonInterpreter;
@@ -315,14 +316,29 @@ class PythonBridgeManager implements AutoCloseable {
 
     @Override
     public void close() throws Exception {
-        if (pythonActionExecutor != null) {
-            pythonActionExecutor.close();
-        }
-        if (pythonInterpreter != null) {
-            pythonInterpreter.close();
+        // Close every component even when an earlier one fails, so a failing 
action executor
+        // cannot leak the interpreter or the environment manager. The first 
failure is
+        // rethrown with the later ones suppressed.
+        //
+        // The ladder catches Throwable, not Exception, and IOUtils.closeAll 
is deliberately not
+        // used: both stop at the first non-Exception Throwable without 
closing what follows, and
+        // what follows here is the native Python state.
+        Throwable firstFailure = null;
+        for (AutoCloseable closeable :
+                new AutoCloseable[] {
+                    pythonActionExecutor, pythonInterpreter, 
pythonEnvironmentManager
+                }) {
+            if (closeable == null) {
+                continue;
+            }
+            try {
+                closeable.close();
+            } catch (Throwable t) {
+                firstFailure = ExceptionUtils.firstOrSuppressed(t, 
firstFailure);
+            }
         }
-        if (pythonEnvironmentManager != null) {
-            pythonEnvironmentManager.close();
+        if (firstFailure != null) {
+            ExceptionUtils.rethrowException(firstFailure);
         }
     }
 }
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
index d3740277..39f73f42 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
@@ -25,6 +25,7 @@ import org.apache.flink.agents.plan.AgentPlan;
 import org.apache.flink.agents.plan.PythonFunction;
 import org.apache.flink.agents.runtime.python.context.PythonRunnerContextImpl;
 import org.apache.flink.types.Row;
+import org.apache.flink.util.ExceptionUtils;
 import pemja.core.PythonInterpreter;
 import pemja.core.object.PyObject;
 
@@ -34,7 +35,7 @@ import java.util.concurrent.atomic.AtomicLong;
 import static org.apache.flink.util.Preconditions.checkState;
 
 /** Execute the corresponding Python action in the agent. */
-public class PythonActionExecutor {
+public class PythonActionExecutor implements AutoCloseable {
 
     private static final String PYTHON_IMPORTS =
             "from flink_agents.plan import function\n"
@@ -201,20 +202,36 @@ public class PythonActionExecutor {
         return (boolean) ((Object[]) invokeResult)[0];
     }
 
+    @Override
     public void close() throws Exception {
-        if (interpreter != null) {
-            if (pythonAsyncThreadPool != null) {
+        // The two Python-side cleanups are independent, so attempt both even 
when the first
+        // fails. Skipping the runner-context cleanup leaves that context's 
long-term memory and
+        // resource cache unreleased, and PythonBridgeManager closes the 
interpreter right behind
+        // us, so there is no later chance to run it. The first failure is 
rethrown with the later
+        // one suppressed, matching the ladders in the managers above.
+        if (interpreter == null) {
+            return;
+        }
+        Throwable firstFailure = null;
+        if (pythonAsyncThreadPool != null) {
+            try {
                 interpreter.invoke(CLOSE_ASYNC_THREAD_POOL, 
pythonAsyncThreadPool);
+            } catch (Throwable t) {
+                firstFailure = ExceptionUtils.firstOrSuppressed(t, 
firstFailure);
             }
-
-            if (pythonRunnerContext != null) {
-                try {
-                    interpreter.invoke(CLOSE_FLINK_RUNNER_CONTEXT, 
pythonRunnerContext);
-                } finally {
-                    pythonRunnerContext = null;
-                }
+        }
+        if (pythonRunnerContext != null) {
+            try {
+                interpreter.invoke(CLOSE_FLINK_RUNNER_CONTEXT, 
pythonRunnerContext);
+            } catch (Throwable t) {
+                firstFailure = ExceptionUtils.firstOrSuppressed(t, 
firstFailure);
+            } finally {
+                pythonRunnerContext = null;
             }
         }
+        if (firstFailure != null) {
+            ExceptionUtils.rethrowException(firstFailure);
+        }
     }
 
     /** Failed to execute Python action. */
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java 
b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java
index 9111edf6..0acee277 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java
@@ -20,6 +20,7 @@ package org.apache.flink.agents.runtime.skill;
 
 import org.apache.flink.agents.api.skills.SkillSourceSpec;
 import org.apache.flink.agents.api.skills.Skills;
+import org.apache.flink.util.ExceptionUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -211,7 +212,8 @@ public class SkillManager implements AutoCloseable {
      * <p>Mirrors {@code ResourceCache.close()}: the first failure is rethrown 
after every repo has
      * been attempted, with subsequent failures attached as suppressed 
exceptions. This surfaces
      * real shutdown bugs (locked files, permission denied, disk full) instead 
of silently
-     * swallowing them.
+     * swallowing them. As there, a non-{@code Exception} {@code Throwable} 
does not stop the
+     * remaining repos, and reaches the caller unwrapped.
      */
     @Override
     public void close() throws Exception {
@@ -225,20 +227,20 @@ public class SkillManager implements AutoCloseable {
         // contributes multiple skills.
         Set<SkillRepository> unique = Collections.newSetFromMap(new 
IdentityHashMap<>());
         unique.addAll(openedRepos);
-        Exception firstException = null;
+        // Catches Throwable, not Exception: a repo failing with an Error 
would otherwise skip the
+        // repos behind it, and ResourceContextImpl.close() clears its manager 
reference in a
+        // finally, so nothing can retry the ones that were skipped. This is 
the nested branch of
+        // the close-all guarantee that ResourceCache.close() makes one level 
up.
+        Throwable firstFailure = null;
         for (SkillRepository repo : unique) {
             try {
                 repo.close();
-            } catch (Exception e) {
-                if (firstException == null) {
-                    firstException = e;
-                } else {
-                    firstException.addSuppressed(e);
-                }
+            } catch (Throwable t) {
+                firstFailure = ExceptionUtils.firstOrSuppressed(t, 
firstFailure);
             }
         }
-        if (firstException != null) {
-            throw firstException;
+        if (firstFailure != null) {
+            ExceptionUtils.rethrowException(firstFailure);
         }
     }
 
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java 
b/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java
index b0e5a685..b51f7e0c 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java
@@ -34,18 +34,31 @@ import org.apache.flink.agents.api.resource.ResourceType;
 import org.apache.flink.agents.api.resource.SerializableResource;
 import org.apache.flink.agents.api.resource.python.PythonResourceAdapter;
 import org.apache.flink.agents.api.resource.python.PythonResourceWrapper;
+import org.apache.flink.agents.api.skills.SkillSourceSpec;
+import org.apache.flink.agents.api.skills.Skills;
 import org.apache.flink.agents.api.vectorstores.Document;
 import org.apache.flink.agents.api.vectorstores.VectorStoreQuery;
 import org.apache.flink.agents.api.vectorstores.VectorStoreQueryResult;
 import org.apache.flink.agents.plan.AgentPlan;
+import org.apache.flink.agents.runtime.resource.ResourceContextImpl;
+import org.apache.flink.agents.runtime.skill.AgentSkill;
+import org.apache.flink.agents.runtime.skill.SkillManager;
+import org.apache.flink.agents.runtime.skill.SkillRepository;
+import org.apache.flink.agents.runtime.skill.SkillSourceRegistry;
 import org.junit.jupiter.api.Test;
 import pemja.core.object.PyObject;
 
+import java.lang.reflect.Field;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.catchThrowable;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 
 /** Tests for {@link ResourceCache}. */
 public class ResourceCacheTest {
@@ -274,4 +287,184 @@ public class ResourceCacheTest {
         Resource myToolAgain = cache.getResource("myTool", ResourceType.TOOL);
         assertThat(myTool).isSameAs(myToolAgain);
     }
+
+    /**
+     * A cached resource failing with a non-{@code Exception} {@code 
Throwable} must not strand the
+     * remaining resources, the cache clear, or the resource context. {@code
+     * ActionExecutionOperator.close()} closes this cache before the Python 
interpreter precisely
+     * because cached resources may hold Python references, so leaving 
resources open here while the
+     * interpreter behind them is torn down would break that ordering.
+     */
+    @Test
+    public void closeClosesEveryResourceWhenAnEarlierResourceThrowsError() 
throws Exception {
+        ResourceCache cache = new ResourceCache(new HashMap<>());
+        OutOfMemoryError failure = new OutOfMemoryError("resource close 
failed");
+        RecordingResource failing = new RecordingResource(ResourceType.TOOL, 
failure);
+        RecordingResource surviving = new 
RecordingResource(ResourceType.CHAT_MODEL, null);
+        cache.put("failing", ResourceType.TOOL, failing);
+        cache.put("surviving", ResourceType.CHAT_MODEL, surviving);
+        // Stands in for the lazily-cached skill manager, so that 
resourceContext.close() running
+        // is observable rather than merely assumed from its position in the 
method.
+        SkillManager skillManager = mock(SkillManager.class);
+        setSkillManager(cache.getResourceContext(), skillManager);
+
+        // The Error reaches the caller unchanged rather than wrapped in an 
Exception, and with
+        // nothing attached to it.
+        assertThatThrownBy(cache::close)
+                .isSameAs(failure)
+                .satisfies(thrown -> 
assertThat(thrown.getSuppressed()).isEmpty());
+
+        assertThat(failing.closed).isTrue();
+        assertThat(surviving.closed).isTrue();
+        // Neither the cache clear nor the resource context is skipped by the 
Error.
+        assertThat(cachedResources(cache)).isEmpty();
+        verify(skillManager).close();
+    }
+
+    /** The first failure is rethrown and any later one is attached as 
suppressed, never dropped. */
+    @Test
+    public void closeReportsFirstResourceFailureWithLaterOnesSuppressed() 
throws Exception {
+        ResourceCache cache = new ResourceCache(new HashMap<>());
+        RecordingResource first =
+                new RecordingResource(ResourceType.TOOL, new 
IllegalStateException("first"));
+        RecordingResource second =
+                new RecordingResource(ResourceType.TOOL, new 
IllegalStateException("second"));
+        cache.put("first", ResourceType.TOOL, first);
+        cache.put("second", ResourceType.TOOL, second);
+
+        Throwable thrown = catchThrowable(cache::close);
+
+        // Iteration order over the cache is unspecified, so pin the 
aggregation rather than which
+        // of the two lands first: one is thrown and the other is suppressed 
on it.
+        assertThat(thrown).isInstanceOf(IllegalStateException.class);
+        assertThat(thrown.getSuppressed()).hasSize(1);
+        assertThat(new String[] {thrown.getMessage(), 
thrown.getSuppressed()[0].getMessage()})
+                .containsExactlyInAnyOrder("first", "second");
+        assertThat(first.closed).isTrue();
+        assertThat(second.closed).isTrue();
+    }
+
+    /**
+     * The close-all guarantee has to reach the nested skill repositories, not 
stop at {@code
+     * ResourceContextImpl}. Exercised through the real production path — 
{@code
+     * ResourceCache.close()} → {@code ResourceContextImpl.close()} → {@code 
SkillManager.close()} →
+     * the repos — because {@code ResourceContextImpl} clears its manager 
reference in a {@code
+     * finally}, so a repo skipped here can never be retried and leaks its 
temp directory.
+     */
+    @Test
+    public void closeClosesEverySkillRepositoryWhenAnEarlierRepoThrowsError() 
throws Exception {
+        // Both repos fail, so the assertions do not depend on the de-dup 
set's iteration order:
+        // a handler narrowed to Exception anywhere along the chain stops at 
whichever runs first
+        // and leaves the other unclosed, which fails here either way round.
+        Error firstBoom = new Error("repo close failed");
+        Error secondBoom = new Error("other repo close failed");
+        RecordingRepo failing = new RecordingRepo("alpha", firstBoom);
+        RecordingRepo surviving = new RecordingRepo("beta", secondBoom);
+        AtomicInteger seq = new AtomicInteger();
+        List<RecordingRepo> ordered = List.of(failing, surviving);
+        SkillSourceRegistry.register(
+                "test-resource-cache-close-error",
+                (params, cl) -> ordered.get(seq.getAndIncrement()));
+        Skills skills =
+                new Skills(
+                        List.of(
+                                new 
SkillSourceSpec("test-resource-cache-close-error", Map.of()),
+                                new 
SkillSourceSpec("test-resource-cache-close-error", Map.of())));
+
+        ResourceCache cache = new ResourceCache(new HashMap<>());
+        cache.put(Skills.SKILLS_CONFIG, ResourceType.SKILLS, skills);
+        // Force the lazily-cached SkillManager to exist, so close() has repos 
to release.
+        cache.getResourceContext().getSkillDirs(List.of("alpha"));
+
+        // The Error reaches the caller unwrapped, through both intervening 
close() methods.
+        Throwable thrown = catchThrowable(cache::close);
+
+        assertThat(thrown).isInstanceOf(Error.class);
+        assertThat(failing.closed).isTrue();
+        assertThat(surviving.closed).isTrue();
+        assertThat(thrown.getSuppressed()).hasSize(1);
+        assertThat(List.of(thrown, thrown.getSuppressed()[0]))
+                .containsExactlyInAnyOrder(firstBoom, secondBoom);
+    }
+
+    /** A skill repository that records its close and can be made to fail it. 
*/
+    private static final class RecordingRepo implements SkillRepository {
+        private final AgentSkill skill;
+        private final Throwable failure;
+        private boolean closed = false;
+
+        private RecordingRepo(String skillName, Throwable failure) {
+            this.skill = new AgentSkill(skillName, "fake", "body", null, null, 
null);
+            this.failure = failure;
+        }
+
+        @Override
+        public AgentSkill getSkill(String name) {
+            return name.equals(skill.getName()) ? skill : null;
+        }
+
+        @Override
+        public List<AgentSkill> getSkills() {
+            return List.of(skill);
+        }
+
+        @Override
+        public Map<String, String> getResources(String name) {
+            return Map.of();
+        }
+
+        @Override
+        public void close() {
+            closed = true;
+            if (failure instanceof Error) {
+                throw (Error) failure;
+            }
+            if (failure instanceof RuntimeException) {
+                throw (RuntimeException) failure;
+            }
+        }
+    }
+
+    private static void setSkillManager(ResourceContextImpl context, 
SkillManager skillManager)
+            throws Exception {
+        Field field = 
ResourceContextImpl.class.getDeclaredField("skillManager");
+        field.setAccessible(true);
+        field.set(context, skillManager);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<ResourceType, Map<String, Resource>> 
cachedResources(ResourceCache cache)
+            throws Exception {
+        Field field = ResourceCache.class.getDeclaredField("cache");
+        field.setAccessible(true);
+        return (Map<ResourceType, Map<String, Resource>>) field.get(cache);
+    }
+
+    /** Records whether {@code close()} ran, and optionally fails it. */
+    private static final class RecordingResource extends Resource {
+        private final ResourceType type;
+        private final Throwable failure;
+        private boolean closed = false;
+
+        private RecordingResource(ResourceType type, Throwable failure) {
+            this.type = type;
+            this.failure = failure;
+        }
+
+        @Override
+        public ResourceType getResourceType() {
+            return type;
+        }
+
+        @Override
+        public void close() throws Exception {
+            closed = true;
+            if (failure instanceof Error) {
+                throw (Error) failure;
+            }
+            if (failure instanceof Exception) {
+                throw (Exception) failure;
+            }
+        }
+    }
 }
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java
index 9fcab04d..b92f887e 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java
@@ -48,11 +48,13 @@ import org.apache.flink.agents.plan.actions.ToolCallAction;
 import 
org.apache.flink.agents.plan.resourceprovider.JavaSerializableResourceProvider;
 import org.apache.flink.agents.plan.resourceprovider.ResourceProvider;
 import org.apache.flink.agents.plan.tools.FunctionTool;
+import org.apache.flink.agents.runtime.ResourceCache;
 import org.apache.flink.agents.runtime.actionstate.ActionState;
 import org.apache.flink.agents.runtime.actionstate.ActionStateSerde;
 import org.apache.flink.agents.runtime.actionstate.ActionStateUtil;
 import org.apache.flink.agents.runtime.actionstate.CallResult;
 import org.apache.flink.agents.runtime.actionstate.InMemoryActionStateStore;
+import org.apache.flink.agents.runtime.eventlog.EventLogWriter;
 import org.apache.flink.agents.runtime.eventlog.FileEventLogger;
 import org.apache.flink.agents.runtime.eventlog.Slf4jEventLogger;
 import org.apache.flink.agents.runtime.memory.Mem0LongTermMemory;
@@ -60,6 +62,8 @@ import org.apache.flink.api.common.typeinfo.TypeInformation;
 import org.apache.flink.api.java.functions.KeySelector;
 import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
 import org.apache.flink.runtime.state.KeyGroupRangeAssignment;
+import org.apache.flink.streaming.api.operators.AbstractStreamOperator;
+import org.apache.flink.streaming.api.operators.StreamOperatorStateHandler;
 import org.apache.flink.streaming.api.watermark.Watermark;
 import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
 import org.apache.flink.streaming.runtime.tasks.mailbox.TaskMailbox;
@@ -69,6 +73,7 @@ import org.apache.flink.util.ExceptionUtils;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
 
 import java.io.IOException;
 import java.io.Serializable;
@@ -90,6 +95,9 @@ import java.util.stream.Collectors;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.api.Assertions.catchThrowable;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
 
 /** Tests for {@link ActionExecutionOperator}. */
 public class ActionExecutionOperatorTest {
@@ -615,6 +623,173 @@ public class ActionExecutionOperatorTest {
         ltmField.set(operator, ltm);
     }
 
+    private static void replaceOperatorField(
+            ActionExecutionOperator<?, ?> operator, String name, Object value) 
throws Exception {
+        Field field = ActionExecutionOperator.class.getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(operator, value);
+    }
+
+    /**
+     * Swaps the state handler {@link AbstractStreamOperator} inherits, 
returning the previous one.
+     *
+     * <p>{@code super.close()} compiles to {@code stateHandler.dispose()} and 
binds statically, so
+     * a subclass cannot intercept the call. Replacing the inherited handler 
is what makes the super
+     * call observable, and what lets it be made to fail.
+     */
+    private static StreamOperatorStateHandler replaceStateHandler(
+            ActionExecutionOperator<?, ?> operator, StreamOperatorStateHandler 
handler)
+            throws Exception {
+        Field field = 
AbstractStreamOperator.class.getDeclaredField("stateHandler");
+        field.setAccessible(true);
+        StreamOperatorStateHandler previous = (StreamOperatorStateHandler) 
field.get(operator);
+        field.set(operator, handler);
+        return previous;
+    }
+
+    private static KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
openCloseTestHarness()
+            throws Exception {
+        KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> testHarness 
=
+                new KeyedOneInputStreamOperatorTestHarness<>(
+                        new 
ActionExecutionOperatorFactory(TestAgent.getAgentPlan(false), true),
+                        (KeySelector<Long, Long>) value -> value,
+                        TypeInformation.of(Long.class));
+        testHarness.open();
+        return testHarness;
+    }
+
+    /** The operator's five closeable components, stubbed so each close is 
observable. */
+    private static final class CloseComponents {
+        private final ResourceCache resourceCache = mock(ResourceCache.class);
+        private final ActionTaskContextManager contextManager =
+                mock(ActionTaskContextManager.class);
+        private final PythonBridgeManager pythonBridge = 
mock(PythonBridgeManager.class);
+        private final EventLogWriter eventLogWriter = 
mock(EventLogWriter.class);
+        private final DurableExecutionManager durableExecManager =
+                mock(DurableExecutionManager.class);
+
+        private void installInto(ActionExecutionOperator<?, ?> operator) 
throws Exception {
+            replaceOperatorField(operator, "resourceCache", resourceCache);
+            replaceOperatorField(operator, "contextManager", contextManager);
+            replaceOperatorField(operator, "pythonBridge", pythonBridge);
+            replaceOperatorField(operator, "eventLogWriter", eventLogWriter);
+            replaceOperatorField(operator, "durableExecManager", 
durableExecManager);
+        }
+
+        /** Detaches the mocks so the harness teardown does not re-trigger the 
failure. */
+        private void detachFrom(ActionExecutionOperator<?, ?> operator) throws 
Exception {
+            replaceOperatorField(operator, "resourceCache", null);
+            replaceOperatorField(operator, "contextManager", null);
+            replaceOperatorField(operator, "pythonBridge", null);
+            replaceOperatorField(operator, "eventLogWriter", null);
+            replaceOperatorField(operator, "durableExecManager", null);
+        }
+
+        /**
+         * Verifies every component was released, in the documented order, 
with {@code
+         * super.close()} last.
+         *
+         * <p>Order is load-bearing rather than incidental: {@code 
resourceCache} must close before
+         * {@code pythonBridge} because cached resources may hold Python 
references, and {@code
+         * super.close()} disposes the state backends the components run 
against.
+         */
+        private void verifyClosedInOrder(StreamOperatorStateHandler 
stateHandler) throws Exception {
+            InOrder inOrder =
+                    inOrder(
+                            resourceCache,
+                            contextManager,
+                            pythonBridge,
+                            eventLogWriter,
+                            durableExecManager,
+                            stateHandler);
+            inOrder.verify(resourceCache).close();
+            inOrder.verify(contextManager).close();
+            inOrder.verify(pythonBridge).close();
+            inOrder.verify(eventLogWriter).close();
+            inOrder.verify(durableExecManager).close();
+            inOrder.verify(stateHandler).dispose();
+        }
+    }
+
+    /**
+     * A failing component must not strand the ones behind it. This matters 
most for {@code
+     * resourceCache}, which closes first and aggregates its own failures, and 
for {@code
+     * pythonBridge}, which releases the embedded Python interpreter.
+     *
+     * <p>Also pins that {@code super.close()} still runs. {@link 
AbstractStreamOperator#close()}
+     * disposes the state handler, so skipping it strands the state backends.
+     */
+    @Test
+    void closeClosesEveryComponentWhenAnEarlierCloseFails() throws Exception {
+        KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> testHarness 
=
+                openCloseTestHarness();
+        ActionExecutionOperator<Long, Object> operator =
+                (ActionExecutionOperator<Long, Object>) 
testHarness.getOperator();
+
+        CloseComponents components = new CloseComponents();
+        doThrow(new IllegalStateException("resource cache close failed"))
+                .when(components.resourceCache)
+                .close();
+        components.installInto(operator);
+        StreamOperatorStateHandler stateHandler = 
mock(StreamOperatorStateHandler.class);
+        StreamOperatorStateHandler realStateHandler = 
replaceStateHandler(operator, stateHandler);
+
+        try {
+            assertThatThrownBy(operator::close)
+                    .isInstanceOf(IllegalStateException.class)
+                    .hasMessage("resource cache close failed")
+                    // Contract 3: with super.close() healthy, nothing is 
attached to the failure.
+                    .satisfies(thrown -> 
assertThat(thrown.getSuppressed()).isEmpty());
+
+            components.verifyClosedInOrder(stateHandler);
+        } finally {
+            components.detachFrom(operator);
+            replaceStateHandler(operator, realStateHandler);
+            testHarness.close();
+        }
+    }
+
+    /**
+     * A {@code super.close()} failure must aggregate with the component 
failures rather than
+     * replace them: the earlier component failure still reaches the caller, 
with the super failure
+     * attached to it as suppressed.
+     */
+    @Test
+    void closeAggregatesSuperCloseFailureWithComponentFailure() throws 
Exception {
+        KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> testHarness 
=
+                openCloseTestHarness();
+        ActionExecutionOperator<Long, Object> operator =
+                (ActionExecutionOperator<Long, Object>) 
testHarness.getOperator();
+
+        CloseComponents components = new CloseComponents();
+        doThrow(new IllegalStateException("resource cache close failed"))
+                .when(components.resourceCache)
+                .close();
+        components.installInto(operator);
+        StreamOperatorStateHandler stateHandler = 
mock(StreamOperatorStateHandler.class);
+        doThrow(new IllegalStateException("state handler dispose failed"))
+                .when(stateHandler)
+                .dispose();
+        StreamOperatorStateHandler realStateHandler = 
replaceStateHandler(operator, stateHandler);
+
+        try {
+            assertThatThrownBy(operator::close)
+                    .isInstanceOf(IllegalStateException.class)
+                    .hasMessage("resource cache close failed")
+                    .satisfies(
+                            thrown ->
+                                    assertThat(thrown.getSuppressed())
+                                            .extracting(Throwable::getMessage)
+                                            .containsExactly("state handler 
dispose failed"));
+
+            components.verifyClosedInOrder(stateHandler);
+        } finally {
+            components.detachFrom(operator);
+            replaceStateHandler(operator, realStateHandler);
+            testHarness.close();
+        }
+    }
+
     /** Java-side stand-in for the Python-backed LTM wrapper used to observe 
the failure path. */
     private static final class RecordingMem0LongTermMemory extends 
Mem0LongTermMemory {
         private final List<String> recordedKeys = new ArrayList<>();
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionTaskContextManagerTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionTaskContextManagerTest.java
index 468a3ed7..a1ea1b0c 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionTaskContextManagerTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionTaskContextManagerTest.java
@@ -25,6 +25,7 @@ import org.apache.flink.agents.plan.actions.Action;
 import org.apache.flink.agents.runtime.ResourceCache;
 import org.apache.flink.agents.runtime.actionstate.ActionState;
 import org.apache.flink.agents.runtime.actionstate.InMemoryActionStateStore;
+import org.apache.flink.agents.runtime.async.ContinuationActionExecutor;
 import org.apache.flink.agents.runtime.async.ContinuationContext;
 import org.apache.flink.agents.runtime.context.JavaRunnerContextImpl;
 import org.apache.flink.agents.runtime.context.RunnerContextImpl;
@@ -40,6 +41,7 @@ import org.apache.flink.core.memory.DataInputDeserializer;
 import org.apache.flink.core.memory.DataOutputSerializer;
 import org.junit.jupiter.api.Test;
 
+import java.lang.reflect.Field;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -50,6 +52,7 @@ import static 
org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
@@ -57,6 +60,64 @@ import static org.mockito.Mockito.verify;
 /** Contract tests for {@link ActionTaskContextManager}. */
 class ActionTaskContextManagerTest {
 
+    /**
+     * A failing runner context must not strand the continuation executor, 
which owns the async
+     * thread pool and would otherwise keep non-daemon threads alive after the 
operator closes.
+     */
+    @Test
+    void closeClosesContinuationExecutorWhenRunnerContextFails() throws 
Exception {
+        ActionTaskContextManager mgr = new ActionTaskContextManager(1);
+        RunnerContextImpl failingContext = mock(RunnerContextImpl.class);
+        ContinuationActionExecutor continuationExecutor = 
mock(ContinuationActionExecutor.class);
+        doThrow(new IllegalStateException("runner context close failed"))
+                .when(failingContext)
+                .close();
+
+        setField(mgr, "runnerContext", failingContext);
+        setField(mgr, "continuationActionExecutor", continuationExecutor);
+
+        assertThatThrownBy(mgr::close)
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessage("runner context close failed")
+                // Contract 3: a lone failure arrives with nothing attached to 
it.
+                .satisfies(thrown -> 
assertThat(thrown.getSuppressed()).isEmpty());
+
+        verify(continuationExecutor).close();
+    }
+
+    /** The first failure is rethrown and the later one is attached as 
suppressed, never dropped. */
+    @Test
+    void closeReportsFirstFailureWithLaterOneSuppressed() throws Exception {
+        ActionTaskContextManager mgr = new ActionTaskContextManager(1);
+        RunnerContextImpl failingContext = mock(RunnerContextImpl.class);
+        ContinuationActionExecutor failingExecutor = 
mock(ContinuationActionExecutor.class);
+        doThrow(new IllegalStateException("runner context close failed"))
+                .when(failingContext)
+                .close();
+        doThrow(new IllegalStateException("continuation executor close 
failed"))
+                .when(failingExecutor)
+                .close();
+
+        setField(mgr, "runnerContext", failingContext);
+        setField(mgr, "continuationActionExecutor", failingExecutor);
+
+        assertThatThrownBy(mgr::close)
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessage("runner context close failed")
+                .satisfies(
+                        thrown ->
+                                assertThat(thrown.getSuppressed())
+                                        .extracting(Throwable::getMessage)
+                                        .containsExactly("continuation 
executor close failed"));
+    }
+
+    private static void setField(ActionTaskContextManager mgr, String name, 
Object value)
+            throws Exception {
+        Field field = ActionTaskContextManager.class.getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(mgr, value);
+    }
+
     @Test
     void perTaskMapsAreIsolatedAcrossPutGetRemove() throws Exception {
         try (ActionTaskContextManager mgr = new ActionTaskContextManager(1)) {
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/PythonBridgeManagerTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/PythonBridgeManagerTest.java
index 17dce19f..b4daf548 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/PythonBridgeManagerTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/PythonBridgeManagerTest.java
@@ -20,14 +20,24 @@ package org.apache.flink.agents.runtime.operator;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.plan.AgentPlan;
 import org.apache.flink.agents.plan.actions.Action;
+import org.apache.flink.agents.runtime.env.PythonEnvironmentManager;
+import org.apache.flink.agents.runtime.python.utils.PythonActionExecutor;
 import org.apache.flink.api.common.ExecutionConfig;
 import org.apache.flink.api.common.JobID;
 import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+import pemja.core.PythonInterpreter;
 
+import java.lang.reflect.Field;
 import java.util.List;
 import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 
 /** Contract tests for {@link PythonBridgeManager}. */
 class PythonBridgeManagerTest {
@@ -59,4 +69,103 @@ class PythonBridgeManagerTest {
             assertThat(bridge.getPythonRunnerContext()).isNull();
         }
     }
+
+    /**
+     * A failing action executor must not strand the interpreter or the 
environment manager: both
+     * hold native Python state that leaks for the lifetime of the TaskManager 
if never closed.
+     *
+     * <p>Also pins the close order documented on the class, which is 
load-bearing rather than
+     * incidental: {@link PythonActionExecutor#close()} calls back into the 
interpreter, so it has
+     * to run before the interpreter is closed.
+     */
+    @Test
+    void closeReleasesInterpreterAndEnvironmentWhenActionExecutorFails() 
throws Exception {
+        PythonBridgeManager bridge = new PythonBridgeManager();
+        PythonActionExecutor actionExecutor = mock(PythonActionExecutor.class);
+        PythonInterpreter interpreter = mock(PythonInterpreter.class);
+        PythonEnvironmentManager environmentManager = 
mock(PythonEnvironmentManager.class);
+        doThrow(new IllegalStateException("action executor close failed"))
+                .when(actionExecutor)
+                .close();
+
+        setField(bridge, "pythonActionExecutor", actionExecutor);
+        setField(bridge, "pythonInterpreter", interpreter);
+        setField(bridge, "pythonEnvironmentManager", environmentManager);
+
+        assertThatThrownBy(bridge::close)
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessage("action executor close failed")
+                // Contract 3: a lone failure arrives with nothing attached to 
it.
+                .satisfies(thrown -> 
assertThat(thrown.getSuppressed()).isEmpty());
+
+        InOrder inOrder = inOrder(actionExecutor, interpreter, 
environmentManager);
+        inOrder.verify(actionExecutor).close();
+        inOrder.verify(interpreter).close();
+        inOrder.verify(environmentManager).close();
+    }
+
+    /** The first failure is rethrown and any later one is attached as 
suppressed, never dropped. */
+    @Test
+    void closeReportsFirstFailureWithLaterOnesSuppressed() throws Exception {
+        PythonBridgeManager bridge = new PythonBridgeManager();
+        PythonActionExecutor actionExecutor = mock(PythonActionExecutor.class);
+        PythonInterpreter interpreter = mock(PythonInterpreter.class);
+        PythonEnvironmentManager environmentManager = 
mock(PythonEnvironmentManager.class);
+        doThrow(new IllegalStateException("action executor close failed"))
+                .when(actionExecutor)
+                .close();
+        doThrow(new IllegalStateException("environment manager close failed"))
+                .when(environmentManager)
+                .close();
+
+        setField(bridge, "pythonActionExecutor", actionExecutor);
+        setField(bridge, "pythonInterpreter", interpreter);
+        setField(bridge, "pythonEnvironmentManager", environmentManager);
+
+        assertThatThrownBy(bridge::close)
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessage("action executor close failed")
+                .satisfies(
+                        thrown ->
+                                assertThat(thrown.getSuppressed())
+                                        .extracting(Throwable::getMessage)
+                                        .containsExactly("environment manager 
close failed"));
+
+        verify(interpreter).close();
+    }
+
+    /**
+     * Pins the handler to {@code Throwable}. A non-{@code Exception} failure 
from the action
+     * executor must still release the native Python state; a {@code catch 
(Exception)} ladder or
+     * {@code IOUtils.closeAll} would stop here and leak it.
+     */
+    @Test
+    void closeReleasesInterpreterAndEnvironmentWhenActionExecutorThrowsError() 
throws Exception {
+        PythonBridgeManager bridge = new PythonBridgeManager();
+        PythonActionExecutor actionExecutor = mock(PythonActionExecutor.class);
+        PythonInterpreter interpreter = mock(PythonInterpreter.class);
+        PythonEnvironmentManager environmentManager = 
mock(PythonEnvironmentManager.class);
+        OutOfMemoryError failure = new OutOfMemoryError("action executor close 
failed");
+        doThrow(failure).when(actionExecutor).close();
+
+        setField(bridge, "pythonActionExecutor", actionExecutor);
+        setField(bridge, "pythonInterpreter", interpreter);
+        setField(bridge, "pythonEnvironmentManager", environmentManager);
+
+        // The Error reaches the caller unchanged rather than wrapped in an 
Exception, and with
+        // nothing attached to it.
+        assertThatThrownBy(bridge::close)
+                .isSameAs(failure)
+                .satisfies(thrown -> 
assertThat(thrown.getSuppressed()).isEmpty());
+
+        verify(interpreter).close();
+        verify(environmentManager).close();
+    }
+
+    private static void setField(PythonBridgeManager bridge, String name, 
Object value)
+            throws Exception {
+        Field field = PythonBridgeManager.class.getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(bridge, value);
+    }
 }
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
index baf584fa..5169269e 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
@@ -20,6 +20,9 @@ package org.apache.flink.agents.runtime.python.utils;
 import org.apache.flink.types.Row;
 import org.junit.jupiter.api.Test;
 import pemja.core.PythonInterpreter;
+import pemja.core.object.PyObject;
+
+import java.lang.reflect.Field;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -87,6 +90,88 @@ class PythonActionExecutorTest {
                 .hasMessage("bad pickle");
     }
 
+    /**
+     * A failing thread-pool shutdown must not skip the runner-context 
cleanup. That cleanup
+     * releases the Python context's long-term memory and resource cache, and 
{@code
+     * PythonBridgeManager} closes the interpreter immediately behind this 
call, so a skipped
+     * cleanup never runs at all.
+     */
+    @Test
+    void closeCleansRunnerContextWhenThreadPoolShutdownFails() throws 
Exception {
+        PythonInterpreter interpreter = mock(PythonInterpreter.class);
+        PythonActionExecutor executor = newExecutor(interpreter);
+        PyObject threadPool = mock(PyObject.class);
+        PyObject runnerContext = mock(PyObject.class);
+        setField(executor, "pythonAsyncThreadPool", threadPool);
+        setField(executor, "pythonRunnerContext", runnerContext);
+        
when(interpreter.invoke("flink_runner_context.close_async_thread_pool", 
threadPool))
+                .thenThrow(new RuntimeException("thread pool shutdown 
failed"));
+
+        assertThatThrownBy(executor::close)
+                .isInstanceOf(RuntimeException.class)
+                .hasMessage("thread pool shutdown failed")
+                // A lone failure arrives with nothing attached to it.
+                .satisfies(thrown -> 
assertThat(thrown.getSuppressed()).isEmpty());
+
+        verify(interpreter)
+                .invoke("flink_runner_context.close_flink_runner_context", 
runnerContext);
+        // The handle is released even on the failing path, so a repeated 
close cannot double-free.
+        assertThat(executor.getPythonRunnerContext()).isNull();
+    }
+
+    /** The first failure is rethrown and the later one is attached as 
suppressed, never dropped. */
+    @Test
+    void closeReportsFirstFailureWithLaterOneSuppressed() throws Exception {
+        PythonInterpreter interpreter = mock(PythonInterpreter.class);
+        PythonActionExecutor executor = newExecutor(interpreter);
+        PyObject threadPool = mock(PyObject.class);
+        PyObject runnerContext = mock(PyObject.class);
+        setField(executor, "pythonAsyncThreadPool", threadPool);
+        setField(executor, "pythonRunnerContext", runnerContext);
+        
when(interpreter.invoke("flink_runner_context.close_async_thread_pool", 
threadPool))
+                .thenThrow(new RuntimeException("thread pool shutdown 
failed"));
+        
when(interpreter.invoke("flink_runner_context.close_flink_runner_context", 
runnerContext))
+                .thenThrow(new RuntimeException("runner context cleanup 
failed"));
+
+        assertThatThrownBy(executor::close)
+                .isInstanceOf(RuntimeException.class)
+                .hasMessage("thread pool shutdown failed")
+                .satisfies(
+                        thrown ->
+                                assertThat(thrown.getSuppressed())
+                                        .extracting(Throwable::getMessage)
+                                        .containsExactly("runner context 
cleanup failed"));
+    }
+
+    /**
+     * Pins the handler to {@code Throwable}: a non-{@code Exception} failure 
from the thread-pool
+     * shutdown must still release the runner context, and must reach the 
caller unwrapped.
+     */
+    @Test
+    void closeCleansRunnerContextWhenThreadPoolShutdownThrowsError() throws 
Exception {
+        PythonInterpreter interpreter = mock(PythonInterpreter.class);
+        PythonActionExecutor executor = newExecutor(interpreter);
+        PyObject threadPool = mock(PyObject.class);
+        PyObject runnerContext = mock(PyObject.class);
+        setField(executor, "pythonAsyncThreadPool", threadPool);
+        setField(executor, "pythonRunnerContext", runnerContext);
+        OutOfMemoryError failure = new OutOfMemoryError("thread pool shutdown 
failed");
+        
when(interpreter.invoke("flink_runner_context.close_async_thread_pool", 
threadPool))
+                .thenThrow(failure);
+
+        assertThatThrownBy(executor::close).isSameAs(failure);
+
+        verify(interpreter)
+                .invoke("flink_runner_context.close_flink_runner_context", 
runnerContext);
+    }
+
+    private static void setField(PythonActionExecutor executor, String name, 
Object value)
+            throws Exception {
+        Field field = PythonActionExecutor.class.getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(executor, value);
+    }
+
     private static PythonActionExecutor newExecutor(PythonInterpreter 
interpreter)
             throws Exception {
         return new PythonActionExecutor(interpreter, null, null, null, 
"test-job");
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java
index daf8660f..5f72d4a8 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java
@@ -455,8 +455,9 @@ class SkillManagerTest {
         // fails its registration with one Error and then its close() with 
another: a load guard
         // narrowed to Exception would skip the cleanup entirely, and a 
cleanup guard narrowed to
         // Exception would let the close() Error escape and replace the 
registration failure.
-        // One source keeps the assertions deterministic — closeRepos() 
catches only Exception per
-        // repo, so an Error from any repo's close() ends the iteration over 
the remaining ones.
+        // One source keeps the assertions deterministic: with a single repo 
there is no iteration
+        // order to depend on. closeRepos() now continues past an Error too — 
see
+        // closeAttemptsEveryRepoWhenAnEarlierRepoThrowsError.
         Error registrationBoom = new Error("registration-error");
         Error closeBoom = new Error("close-error");
         FakeRepo repo = new FakeRepo("alpha", closeBoom, registrationBoom);
@@ -511,6 +512,46 @@ class SkillManagerTest {
         assertTrue(all.contains(good2Boom), "good2-close must surface");
     }
 
+    @Test
+    void closeAttemptsEveryRepoWhenAnEarlierRepoThrowsError() throws Exception 
{
+        // An Error from one repo's close() must not strand the repos behind 
it. Nothing can retry
+        // them: ResourceContextImpl.close() clears its SkillManager reference 
in a finally, so a
+        // skipped repo leaks its materialized temp directory for the life of 
the JVM.
+        //
+        // Both repos fail so the assertions do not depend on the de-dup set's 
iteration order,
+        // which is unspecified. With a handler narrowed to Exception, 
whichever repo runs first
+        // ends the iteration and the other is left unclosed — that fails here 
either way round.
+        Error firstBoom = new Error("close-error-1");
+        Error secondBoom = new Error("close-error-2");
+        FakeRepo first = new FakeRepo("alpha", firstBoom);
+        FakeRepo second = new FakeRepo("beta", secondBoom);
+
+        AtomicInteger seq = new AtomicInteger();
+        List<FakeRepo> ordered = List.of(first, second);
+        SkillSourceRegistry.register(
+                "test-close-error", (params, cl) -> 
ordered.get(seq.getAndIncrement()));
+
+        Skills config =
+                new Skills(
+                        List.of(
+                                new SkillSourceSpec("test-close-error", 
Map.of()),
+                                new SkillSourceSpec("test-close-error", 
Map.of())));
+
+        SkillManager manager = new SkillManager(config);
+        // The Error reaches the caller unwrapped rather than boxed in an 
Exception.
+        Error thrown = assertThrows(Error.class, manager::close);
+
+        assertTrue(first.closed.get(), "the first repo must be attempted");
+        assertTrue(second.closed.get(), "the repo behind the Error must still 
be closed");
+        List<Throwable> all = new ArrayList<>();
+        all.add(thrown);
+        for (Throwable s : thrown.getSuppressed()) {
+            all.add(s);
+        }
+        assertTrue(all.contains(firstBoom), "the first Error must surface");
+        assertTrue(all.contains(secondBoom), "the second Error must surface");
+    }
+
     @Test
     void closeReleasesRepoDisplacedByDuplicateSkillName() throws Exception {
         // Two sources both contribute a skill named "dup". The skill-name → 
repo map keeps only

Reply via email to