bug #IK6XVN liteflow‑script‑javax‑pro 中 JavaxProExecutor 的 ThreadLocal<Stack<Node>> 泄漏

This commit is contained in:
everywhere.z 2026-08-07 14:08:05 +08:00
parent 039d5cff0c
commit 5e1e9dab2b
4 changed files with 199 additions and 18 deletions

View File

@ -115,9 +115,10 @@ public class JavaxProExecutor extends ScriptExecutor {
@Override
public Object executeScript(ScriptExecuteWrap wrap) throws Exception {
NodeComponent cmp = getExecutableCmp(wrap);
cmp.process();
return cmp.getItemResultMetaValue(wrap.slotIndex);
return withExecutableCmp(wrap, cmp -> {
cmp.process();
return cmp.getItemResultMetaValue(wrap.slotIndex);
});
}
@Override
@ -162,50 +163,78 @@ public class JavaxProExecutor extends ScriptExecutor {
@Override
public boolean executeIsAccess(ScriptExecuteWrap wrap) {
NodeComponent cmp = getExecutableCmp(wrap);
return cmp.isAccess();
return withExecutableCmp(wrap, NodeComponent::isAccess);
}
@Override
public boolean executeIsContinueOnError(ScriptExecuteWrap wrap) {
NodeComponent cmp = getExecutableCmp(wrap);
return cmp.isContinueOnError();
return withExecutableCmp(wrap, NodeComponent::isContinueOnError);
}
@Override
public boolean executeIsEnd(ScriptExecuteWrap wrap) {
NodeComponent cmp = getExecutableCmp(wrap);
return cmp.isEnd();
return withExecutableCmp(wrap, NodeComponent::isEnd);
}
@Override
public void executeBeforeProcess(ScriptExecuteWrap wrap) {
NodeComponent cmp = getExecutableCmp(wrap);
cmp.beforeProcess();
runWithExecutableCmp(wrap, NodeComponent::beforeProcess);
}
@Override
public void executeAfterProcess(ScriptExecuteWrap wrap) {
NodeComponent cmp = getExecutableCmp(wrap);
cmp.afterProcess();
runWithExecutableCmp(wrap, NodeComponent::afterProcess);
}
@Override
public void executeOnSuccess(ScriptExecuteWrap wrap) throws Exception {
NodeComponent cmp = getExecutableCmp(wrap);
cmp.onSuccess();
runWithExecutableCmp(wrap, NodeComponent::onSuccess);
}
@Override
public void executeOnError(ScriptExecuteWrap wrap, Exception e) throws Exception {
NodeComponent cmp = getExecutableCmp(wrap);
cmp.onError(e);
runWithExecutableCmp(wrap, cmp -> cmp.onError(e));
}
@Override
public void executeRollback(ScriptExecuteWrap wrap) throws Exception {
runWithExecutableCmp(wrap, NodeComponent::rollback);
}
/**
* 在编译出的脚本组件上执行一段有返回值的动作
* <p>
* 编译产物是跨执行共享的单例每次调用前都要把当前执行现场refNode 注入进去
* 因此用完必须成对地清理否则 refNodeStackTL 会随调用次数无界增长内存泄漏
* 这里是全类唯一的注入 + 清理点新增入口只要走这个方法就不可能漏掉清理
*
* @param wrap 脚本执行元参数
* @param action 要在脚本组件上执行的动作
* @param <T> 动作的返回值类型
* @param <E> 动作抛出的异常类型不抛受检异常时会推断为 RuntimeException
*/
private <T, E extends Exception> T withExecutableCmp(ScriptExecuteWrap wrap, CmpFunction<T, E> action) throws E {
NodeComponent cmp = getExecutableCmp(wrap);
cmp.rollback();
try {
return action.apply(cmp);
} finally {
cmp.removeRefNode();
}
}
/**
* 在编译出的脚本组件上执行一段无返回值的动作语义同 {@link #withExecutableCmp(ScriptExecuteWrap, CmpFunction)}
* <p>
* 这里没有和上面的方法同名重载是因为 lambda 形态的实参 {@code cmp -> cmp.onError(e)}
* 无法靠返回类型在两个函数式接口之间消歧会导致编译期歧义
*/
private <E extends Exception> void runWithExecutableCmp(ScriptExecuteWrap wrap, CmpConsumer<E> action) throws E {
NodeComponent cmp = getExecutableCmp(wrap);
try {
action.accept(cmp);
} finally {
cmp.removeRefNode();
}
}
private NodeComponent getExecutableCmp(ScriptExecuteWrap wrap){
@ -222,6 +251,16 @@ public class JavaxProExecutor extends ScriptExecutor {
return cmp;
}
@FunctionalInterface
private interface CmpFunction<T, E extends Exception> {
T apply(NodeComponent cmp) throws E;
}
@FunctionalInterface
private interface CmpConsumer<E extends Exception> {
void accept(NodeComponent cmp) throws E;
}
private String convertScript(String script){
//替换掉publicprivateprotected等修饰词
String script1 = script.replaceAll("public class", "class")

View File

@ -0,0 +1,90 @@
package com.yomahub.liteflow.test.script.javaxpro.refnode;
import com.yomahub.liteflow.core.FlowExecutor;
import com.yomahub.liteflow.core.NodeComponent;
import com.yomahub.liteflow.flow.LiteflowResponse;
import com.yomahub.liteflow.script.ScriptExecutor;
import com.yomahub.liteflow.script.ScriptExecutorFactory;
import com.yomahub.liteflow.test.BaseTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import javax.annotation.Resource;
import java.lang.reflect.Field;
import java.util.Map;
import java.util.Stack;
/**
* 验证 javax-pro(liquor) 编译出的脚本组件上的 refNodeStackTL 不会无限增长
* <p>
* 场景预创建有限条 chain各含同一 java 脚本节点同一线程轮询执行不同 chain
* EL 构建时每个节点出现位置都会 clone 出一个新的 Node 对象JavaxProExecutor.getExecutableCmp
* 对编译出的 NodeComponent 调用 setRefNode仅与栈顶做引用相等比较却从不 removeRefNode
* 导致编译组件上的 refNodeStackTL 中的 Stack 随调用次数线性增长内存泄漏
*/
@ExtendWith(SpringExtension.class)
@TestPropertySource(value = "classpath:/refnode/application.properties")
@SpringBootTest(classes = RefNodeStackLeakTest.class)
@EnableAutoConfiguration
@ComponentScan({ "com.yomahub.liteflow.test.script.javaxpro.refnode.cmp" })
public class RefNodeStackLeakTest extends BaseTest {
@Resource
private FlowExecutor flowExecutor;
@Test
public void testRefNodeStackNotLeak() throws Exception {
// 同一线程轮询执行两条 chain两条 chain 中的 s1 是不同的 Node 克隆对象
for (int i = 0; i < 100; i++) {
LiteflowResponse respA = flowExecutor.execute2Resp("chainA", "arg");
Assertions.assertTrue(respA.isSuccess());
LiteflowResponse respB = flowExecutor.execute2Resp("chainB", "arg");
Assertions.assertTrue(respB.isSuccess());
}
Stack<?> refNodeStack = getRefNodeStack("s1");
// 执行结束后编译组件的 refNode 栈应当被清理干净ThreadLocal remove 或栈为空
// 泄漏情况下这里会随执行次数线性增长本用例中为 200
Assertions.assertTrue(refNodeStack == null || refNodeStack.isEmpty(),
"refNodeStackTL leaked, size=" + (refNodeStack == null ? 0 : refNodeStack.size()));
}
/**
* 脚本抛异常时refNode 同样要被清理清理动作放在 finally 中的意义
*/
@Test
public void testRefNodeStackNotLeakOnScriptError() throws Exception {
for (int i = 0; i < 50; i++) {
Assertions.assertFalse(flowExecutor.execute2Resp("errChainA", "arg").isSuccess());
Assertions.assertFalse(flowExecutor.execute2Resp("errChainB", "arg").isSuccess());
}
Stack<?> refNodeStack = getRefNodeStack("s2");
Assertions.assertTrue(refNodeStack == null || refNodeStack.isEmpty(),
"refNodeStackTL leaked on error path, size=" + (refNodeStack == null ? 0 : refNodeStack.size()));
}
@SuppressWarnings("unchecked")
private Stack<?> getRefNodeStack(String nodeId) throws Exception {
ScriptExecutor scriptExecutor = ScriptExecutorFactory.loadInstance().getScriptExecutor("java");
Field mapField = scriptExecutor.getClass().getDeclaredField("compiledScriptMap");
mapField.setAccessible(true);
Map<String, NodeComponent> compiledScriptMap = (Map<String, NodeComponent>) mapField.get(scriptExecutor);
NodeComponent cmp = compiledScriptMap.get(nodeId);
Assertions.assertNotNull(cmp, "compiled script component not found for node " + nodeId);
Field tlField = NodeComponent.class.getDeclaredField("refNodeStackTL");
tlField.setAccessible(true);
ThreadLocal<Stack<?>> tl = (ThreadLocal<Stack<?>>) tlField.get(cmp);
return tl.get();
}
}

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE flow PUBLIC "liteflow" "liteflow.dtd">
<flow>
<nodes>
<node id="s1" name="脚本S1" type="script" language="java">
<![CDATA[
import com.yomahub.liteflow.core.NodeComponent;
import com.yomahub.liteflow.slot.DefaultContext;
public class LeakDemo extends NodeComponent {
@Override
public void process() throws Exception {
DefaultContext ctx = this.getFirstContextBean();
ctx.setData(this.getNodeId(), "executed by " + this.getNodeId());
}
}
]]>
</node>
<!-- 抛异常的脚本节点,用于验证异常路径下 refNode 也会被清理 -->
<node id="s2" name="脚本S2" type="script" language="java">
<![CDATA[
import com.yomahub.liteflow.core.NodeComponent;
public class ErrorDemo extends NodeComponent {
@Override
public void process() throws Exception {
throw new RuntimeException("script error on purpose");
}
}
]]>
</node>
</nodes>
<!-- 两条 chain 引用同一个脚本节点 s1EL 构建时会出现两个不同的 Node 克隆对象 -->
<chain name="chainA">
THEN(s1);
</chain>
<chain name="chainB">
THEN(s1);
</chain>
<!-- 异常场景:两条 chain 引用同一个会抛错的脚本节点 s2 -->
<chain name="errChainA">
THEN(s2);
</chain>
<chain name="errChainB">
THEN(s2);
</chain>
</flow>