Skip to content

修复 pesapi-v8:pesapi_release_value_ref 未 Reset value_persistent 导致 marshal 的 JS 函数泄露#2342

Merged
chexiongsheng merged 1 commit into
Tencent:masterfrom
hwei:leak-fix-master
Jun 4, 2026
Merged

修复 pesapi-v8:pesapi_release_value_ref 未 Reset value_persistent 导致 marshal 的 JS 函数泄露#2342
chexiongsheng merged 1 commit into
Tencent:masterfrom
hwei:leak-fix-master

Conversation

@hwei
Copy link
Copy Markdown
Contributor

@hwei hwei commented Jun 2, 2026

现象

在 v8 后端(pesapi)下,把一个 JS 函数 marshal 成 C# delegate 后,即使随后丢弃该 delegate 并反复触发 C# GC + V8 GC,这个 JS 闭包、以及它捕获的所有对象(含通过 ObjectPool 持有的 C# 对象)都不会被回收,会在整个 isolate 生命周期内持续累积泄露。

影响 native 后端(Editor / Standalone / Android / iOS)。WebGL 用 JS-mock 的 ObjectPool + FinalizationRegistry,不走这条 native 路径,不受影响

根因

JS 函数 → C# delegate 的桥 ScriptToNative_ScriptObjectpesapi_create_value_ref(env, fn, 1) 建了一个 persistent 把该 JS 函数钉住,挂在 ScriptObject 上。这条路径从不调用 pesapi_set_ref_weak,所以 persistent 始终是强引用。

ScriptObject 被 C# GC 回收后,会经 pesapi_release_value_ref 释放该 ref(unity/native/papi-v8/source/PesapiV8Impl.cpp):

void pesapi_release_value_ref(pesapi_value_ref value_ref)
{
    if (--value_ref->ref_count == 0)
    {
        if (!value_ref->env_life_cycle_tracker.expired())
        {
            value_ref->~pesapi_value_ref__();   // <-- 这里
        }
        ::operator delete(static_cast<void*>(value_ref));
    }
}

value_persistent 的类型是 v8::Persistent<v8::Value>,默认 NonCopyablePersistentTraitskResetInDestructor == false —— 析构函数不会自动 Reset()。于是 ~pesapi_value_ref__() 只回收了 C++ 结构体内存,V8 的强 global handle 从未被释放,对应的 JS 函数(及其闭包捕获的一切)就被永久钉住。

对照:C# 对象 → JS 的常规路径会调用 pesapi_set_ref_weak 把 persistent 转弱,弱 handle 即便不显式 Reset() 也会被 V8 GC 清理,所以那条路径看不到这个泄露;问题只在保持强引用、经由 pesapi_release_value_ref 释放的 marshal 路径上暴露。

影响版本

Unity_v3.0.0 到当前 masterPesapiV8Impl.cpp 这段释放逻辑逐字相同,问题一直存在(升级到 3.0.1 / 3.0.2 不能修复)。

修复

在析构前显式 Reset

value_ref->value_persistent.Reset();
value_ref->~pesapi_value_ref__();

(本 PR 即此一处改动。)

最小复现(已验证)

自包含,不依赖任何业务工程。放进任意安装了 PuerTS(v8) 的 Unity 工程,编辑器/反射模式下即可跑(IL2CPP 需生成对应 wrapper)。命令行:-batchmode -quit -executeMethod PuertsLeakRepro.Runner.RunHeadless,或在编辑器里直接调用 Runner.Run(new JsEnv(new DefaultLoader()), 5, 6)

#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using Puerts;
using UnityEngine;

namespace PuertsLeakRepro
{
    // 被追踪存活性的普通 C# 对象。从 JS new(因此进入 ObjectPool),
    // 并被一个 JS 闭包捕获,该闭包随后被 marshal 成 C# delegate。
    public class LeakTarget
    {
        public void Ping() { }
    }

    public static class Probe
    {
        private static readonly List<WeakReference> tracked = new List<WeakReference>();

        // JS 每造一个 LeakTarget 就登记进来,便于事后数存活数;只存 WeakReference,不会钉住对象。
        public static void Track(LeakTarget t) { tracked.Add(new WeakReference(t)); }

        // 接收一个被 marshal 成 C# delegate 的 JS 闭包,然后立刻丢弃。
        // 这就是被测操作:marshal 会建出那个强 value_ref。
        public static void Sink(Action cb) { /* intentionally discarded */ }

        public static void Reset() { tracked.Clear(); }

        // 仍存活(=仍泄露)的 LeakTarget 数。
        public static int AliveCount()
        {
            int n = 0;
            for (int i = tracked.Count - 1; i >= 0; i--)
            {
                if (tracked[i].IsAlive) n++;
                else tracked.RemoveAt(i);
            }
            return n;
        }
    }

    public static class Runner
    {
        // 每轮:从 JS new 一个 LeakTarget(进 ObjectPool)、登记追踪、建一个捕获它的 JS 闭包、
        // 把该闭包 marshal 成 C# Action(Probe.Sink)后丢弃所有 JS 侧引用。
        // 此后唯一能让 LeakTarget 存活的,就只剩那个被 marshal 闭包的强 V8 persistent。
        private const string MarshalJsTemplate = @"
(function () {{
    for (let i = 0; i < {0}; i++) {{
        let o = new CS.PuertsLeakRepro.LeakTarget();
        CS.PuertsLeakRepro.Probe.Track(o);
        let cb = () => o;                      // 闭包捕获 o
        CS.PuertsLeakRepro.Probe.Sink(cb);     // marshal cb -> C# Action,随后丢弃
    }}
}})();";

        /// <summary>
        /// 返回跨堆 GC 后仍存活的 LeakTarget 数。
        /// stock 构建:保持 == n(泄露);打上 value_persistent.Reset() 后:降到 0。
        /// </summary>
        public static int Run(JsEnv env, int n, int rounds)
        {
            if (rounds <= 0) rounds = 6;
            Probe.Reset();
            env.Eval(string.Format(MarshalJsTemplate, n), "leakReproMarshal");
            Debug.Log($"[LeakRepro] afterMarshal alive={Probe.AliveCount()} (n={n})");

            int alive = Probe.AliveCount();
            for (int r = 1; r <= rounds; r++)
            {
                // 跨堆回收顺序:C# GC + finalizer -> Tick(排空待释放的 value_ref)-> V8 GC -> 重复。
                GC.Collect();
                GC.WaitForPendingFinalizers();
                GC.Collect();
                env.Tick();
                env.Backend.LowMemoryNotification();
                env.Tick();
                env.Backend.LowMemoryNotification();
                GC.Collect();
                GC.WaitForPendingFinalizers();
                env.Tick();

                alive = Probe.AliveCount();
                Debug.Log($"[LeakRepro] round{r} alive={alive}");
            }
            Debug.Log($"[LeakRepro] VERDICT: {(alive == 0 ? "FIXED (alive=0)" : $"LEAK (alive={alive}/{n})")}");
            return alive;
        }

        // headless 入口:Unity -batchmode -quit -executeMethod PuertsLeakRepro.Runner.RunHeadless
        // 环境变量:LP_N(默认 5)、LP_ROUNDS(默认 6)。
        public static void RunHeadless()
        {
            int n = int.TryParse(Environment.GetEnvironmentVariable("LP_N"), out var nv) ? nv : 5;
            int rounds = int.TryParse(Environment.GetEnvironmentVariable("LP_ROUNDS"), out var rv) ? rv : 6;
            int exitCode = 0;
            JsEnv env = null;
            try
            {
                env = new JsEnv(new DefaultLoader());
                Debug.Log("[LeakRepro] JsEnv created; CS available = " + env.Eval<bool>("typeof CS !== 'undefined'"));
                Run(env, n, rounds);
            }
            catch (Exception e)
            {
                Debug.LogException(e);
                exitCode = 1;
            }
            finally
            {
                try { env?.Dispose(); } catch { }
                UnityEditor.EditorApplication.Exit(exitCode);
            }
        }
    }
}
#endif

实测(n=5,6 轮 GC,仅用 Backend.LowMemoryNotification() + C# GC 驱动,不依赖 --expose-gc):

构建 结果
修复前(stock Unity_v3.0.0 VERDICT: LEAK (alive=5/5) —— 6 轮后仍全部存活
修复后(同 tag + 本 PR 的一行改动) VERDICT: FIXED (alive=0) —— round1 即全部回收

修复用的 PapiV8.dll 是用本仓库的 unity build plugins (custom backend) workflow(windows runner, backend=papi-v8, Release)编出来的;其余条件完全一致,唯一变量就是这一行 value_persistent.Reset()

v8::Persistent uses NonCopyablePersistentTraits (kResetInDestructor == false), so the implicit ~pesapi_value_ref__() does not release the strong V8 global root. pesapi_release_value_ref freed the C++ struct but never Reset the persistent, so a JS function marshaled into a C# delegate (this path never calls pesapi_set_ref_weak, the persistent stays strong) and everything its closure captures leaked for the isolate lifetime. Affects native backends; WebGL (JS-mock ObjectPool) is unaffected. Add explicit value_persistent.Reset().
@chexiongsheng chexiongsheng merged commit 23c00f1 into Tencent:master Jun 4, 2026
18 of 19 checks passed
chexiongsheng added a commit that referenced this pull request Jun 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants