Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

[net11.0-android] Handler.RemoveCallbacks(Action) leaves earlier repeated posts queued, retaining managed objects and JNI references

未关闭
#13,309 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

@simonrozsival 已经在做这个了。

开始于 2026年10月29日。

  • #13312 来自 @simonrozsival —— 未关闭

评估

难度
4/5
预计耗时
3-5 天
新手友好度
25/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
停滞
技术栈
android, csharp
领域
backend, mobile

调研方向

Start with src/Mono.Android/Android.OS/Handler.cs and src/Mono.Android/Java.Lang/Thread.cs, especially the PostDelayed(Action), RemoveCallbacks(Action), and RunnableImplementor lookup paths described in the issue. Trace how repeated posts, callback execution, queue owners, and tokens affect bookkeeping, including the shared View and drawable callers. Reproduce with the provided RunAsync helper on net11.0-android; done means removing the action cancels all its pending callbacks without retaining their delegates or JNI references.

由索引模型根据 Issue 内容生成。

描述

Area: Mono.Android needs-triage
Android framework version

net11.0-android (CoreCLR).

Affected platform version

Reproduced on an Android 15/API 35 ARM64 emulator with both:

Build Android framework Runtime
Installed .NET 11 RC2 workload 37.2.0.84, revision 801f9ecbab5118b13d21cb52409e507ad4b2feb7 .NET 11.0.0-rc.2.26504.105
Locally built source 37.99.0.285, revision 608c74d49c0f449aa42b357191c94d0ada55cb9e .NET 11.0.0-preview.7.26381.103

The installed SDK was 11.0.100-rc.2.26504.105; source SDK preparation provisioned 12.0.100-alpha.1.26523.103. Loaded framework informational versions and runtime identities were verified inside the running app. These builds use different runtime versions, so their quantitative results are not a controlled runtime-identical A/B comparison. The cancellation defect reproduces independently on both.

Description

Repeatedly posting the same Action through Handler.PostDelayed(Action, ...), then calling Handler.RemoveCallbacks(Action), removes only the most recently created Java runnable. Earlier posts remain queued and retain their managed delegates, captured objects, and JNI references.

Expected: Removing the action cancels its pending callbacks on that handler, without retaining captures from earlier posts of the same action.

Actual: Each operation below leaves one earlier callback queued. Retention grows linearly even after managed/Java collection and completed peer finalization. The callback may also execute later despite cancellation.

This is failed cancellation with potentially long-lived retention, not an assertion that the allocation can never be released: the retained callback can be released when it executes or when the queue is explicitly cleared. The experiment schedules callbacks one hour ahead and completes before they become due.

Ownership/bookkeeping defect

Each Handler.PostDelayed(Action) call creates a new RunnableImplementor.

Its constructor calls instances.AddOrUpdate(handler, this) on a ConditionalWeakTable<Action, RunnableImplementor>, replacing the lookup entry for the preceding post. The Java message queue still owns that preceding runnable.

RunnableImplementor.Remove(Action) returns only the latest wrapper and removes its mapping. Handler.RemoveCallbacks(Action) removes/disposes only that wrapper. The earlier Java runnable remains reachable from the queue, so the interop bridge must preserve its managed peer and captured delegate.

The same lookup is used by View and drawable scheduling helpers. These are related static-analysis leads, not separately device-validated findings. A fix should account for multiple posts, different queue owners/tokens, callback execution cleanup, and the shared callers rather than only changing the measured handler case.

Measured source-framework results

Each scenario runs in a fresh process. The unprofiled matrix performs 20 batches of 500 operations, discards the first five batches for trend analysis, and compares batches 5 and 20: 7,500 additional operations. Every operation captures a new 4,096-byte array. Collection uses fresh unreachable managed/Java-cycle finalization witnesses, three collection rounds, peer-registry draining, and a zero-weak-global-reference check. No gcdump or heapprofd session runs during this matrix.

Scenario Managed live-byte delta Median adjacent-batch managed bytes/operation JNI global-reference delta
Post once, then RemoveCallbacks(action) +6,056 0 0
Post twice, then RemoveCallbacks(action) +33,044,000 4,272.112 +7,500
Post twice, remove action, then clear the handler queue after each batch +32,536 0 0

The repeated-post case additionally accumulated 602,208 Java live bytes and 2,991,040 native allocated bytes. Those whole-process native counters are supporting context, not attribution of particular native allocations.

An independent source-framework run with paired dotnet-gcdump captures at batches 5 and 12 (500 operations/batch) showed:

Managed heap observation Batch 5 Batch 12
RunnableImplementor instances 2,500 6,000
Captured byte arrays baseline baseline +3,500
GC heap bytes 14,338,958 29,831,334

The single-post control did not accumulate runnables. These are separate instrumented runs because gcdump induces collection and introduces diagnostic allocations.

The installed RC2 build independently reproduced exactly one additional gref per operation and approximately 4,272 managed bytes/operation; a longer RC2 run accumulated 7,500 grefs and 33,246,496 managed bytes between batches 5 and 20.

Relation to the broader leak investigation

This was found while investigating additional leak scenarios after #13290. It is a different ownership defect. The current source already contains the peer-control-block finalization fix; the source peer finalization/unregistered-disposal scenarios did not show the stock RC2 allocation-proportional native growth in these runs.

The measurement harness was calibrated with intentional retention controls: 4,096-byte managed arrays measured 4,120 managed bytes/operation, 4,096-byte native allocations measured approximately 4,098 native bytes/operation, and owned JNI global references measured exactly one additional gref/operation. Separate source control/finalization/native-control Perfetto captures passed error/data-loss and periodic-snapshot checks. Native trace sampling does not establish a separate native leak here.

Testing was limited to CoreCLR on one ARM64 emulator. Mono, NativeAOT, other ABIs, View/drawable cancellation, and cross-handler/token behavior have not been validated. Other static leads, such as throwing stream-close cleanup, are not being reported as confirmed leaks in this issue.

Scenarios tested without detecting sustained memory retention

The following cases were also exercised against the locally built source framework at 608c74d49c0f449aa42b357191c94d0ada55cb9e, using CoreCLR on the Android 15/API 35 ARM64 emulator. Each ran in a fresh process for 20 batches of 500 operations, with post-collection measurements and five warmup batches. The table compares batches 5 through 20.

Scenario What we exercised Median adjacent-batch native allocated bytes/operation Median managed live bytes/operation JNI global-reference delta
No-allocation control Same collection and measurement loop, without scenario allocations 10.880 0 0
Rect natural finalization Create temporary bound peers without explicit disposal 0.928 0 0
Rect explicit disposal Create and dispose each peer 1.056 0 0
Unregistered peer disposal UnregisterFromRuntime() followed by DisposeUnlessReferenced() 0.000 0 0
Object-wrapper finalization Temporary managed subclasses of Java.Lang.Object 0.448 0 0
Exception-wrapper finalization Temporary Java.Lang.Throwable instances 0.192 0 0
Single-post callback cancellation Post an action once, then remove it 0.576 0 0
Repeated posts with queue cleanup Same repeated-post workload as the failing case, then clear the exclusively owned handler queue after each batch 1.952 0 0
Stream interop, normal cleanup Read/write Java byte-array streams through InputStreamInvoker/OutputStreamInvoker, then dispose them 0.160 0 0
Native-backed bitmaps Create, fill, recycle, and dispose 32 × 32 ARGB bitmaps 11.360 0 0

Java live memory also had zero median adjacent-batch growth in these cases. Native counters fluctuate because of allocator activity and unrelated runtime allocations; the nonzero values above are not exact per-object leaked-byte counts. In particular, bitmap growth was similar to the no-allocation control. Full time series and endpoint deltas were retained rather than treating a single slope or endpoint as a pass/fail assertion.

The normal stream and bitmap cases also showed no convincing sustained retention on the installed RC2 workload. Conversely, natural peer finalization and unregistered peer disposal were not negative results on RC2: they reproduced the allocation-proportional native growth associated with #13290, even while managed memory and gref counts remained approximately flat. The source-versus-RC2 comparison uses different runtime versions and cannot isolate the effect of an individual fix.

Separate source-framework Perfetto/heapprofd captures covered the no-allocation control, Rect finalization, and the intentional native-retention control. They contained 27, 13, and 12 periodic snapshot timestamps, respectively, and passed automated error/data-loss checks. These establish working native-allocation capture; they do not prove that every native allocation path is leak-free. An initial trace with a profiler-buffer overrun was rejected and excluded from conclusions.

Interpretation: these are bounded “no sustained leak detected under the tested conditions” results, not proof of leak freedom. The stream workload did not test a throwing Close(), and the bitmap workload explicitly recycled its pixel storage. Activity recreation, network cancellation, listener teardown, collectible assembly unloading, Mono, NativeAOT, other ABIs, and the related View/drawable cancellation helpers remain outside the measured coverage. Intentional managed/native/JNI retention controls were calibration cases, not negative results.

Steps to Reproduce
  1. Create a plain net11.0-android app, with SupportedOSPlatformVersion at least 24. No MAUI or external dependencies are required.
  2. Add the following helper and invoke await Task.Run(() => RunAsync()); from the activity while keeping the app visible and device awake.
  3. Run with repeat: true. Restart the app process and repeat with repeat: false. Optionally repeat with clearQueue: true.
  4. Compare post-collection managed live bytes and JNI grefs after warmup. For stronger collection evidence, use finalization witnesses and peer-registry draining as described above; fixed-delay forced GC alone is a minimal reproduction, not a general collection-completion guarantee.
using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Android.OS;
using Java.Interop;

static async Task RunAsync (bool repeat = true, bool clearQueue = false)
{
    using var handler = new Handler (Looper.MainLooper ??
        throw new InvalidOperationException ("Main looper is unavailable."));
    try {
        for (int batch = 0; batch <= 20; batch++) {
            if (batch != 0)
                AllocateBatch (handler, repeat);
            if (clearQueue)
                handler.RemoveCallbacksAndMessages (null);

            for (int pass = 0; pass < 3; pass++) {
                GC.Collect ();
                GC.WaitForPendingFinalizers ();
                JniEnvironment.Runtime.ValueManager.CollectPeers ();
                await Task.Delay (150);
            }

            Android.Util.Log.Info ("CallbackRetention",
                $"batch={batch} managed={GC.GetTotalMemory (false)} " +
                $"grefs={JniEnvironment.Runtime.GlobalReferenceCount}");
        }
    } finally {
        handler.RemoveCallbacksAndMessages (null);
    }
}

[MethodImpl (MethodImplOptions.NoInlining)]
static void AllocateBatch (Handler handler, bool repeat)
{
    for (int i = 0; i < 500; i++)
        ScheduleAndCancel (handler, repeat);
}

[MethodImpl (MethodImplOptions.NoInlining)]
static void ScheduleAndCancel (Handler handler, bool repeat)
{
    byte [] payload = new byte [4096];
    Action action = () => GC.KeepAlive (payload);
    if (!handler.PostDelayed (action, 3_600_000))
        throw new InvalidOperationException ("Post failed.");
    if (repeat && !handler.PostDelayed (action, 3_600_000))
        throw new InvalidOperationException ("Repeated post failed.");
    handler.RemoveCallbacks (action);
}
Did you find any workaround?

For an exclusively owned handler, RemoveCallbacksAndMessages(null) releases the otherwise remaining callbacks; this was verified as a matched control. Do not indiscriminately clear a shared handler, since that also removes unrelated work.

Avoid repeatedly posting the same Action through these convenience overloads before cancellation, or explicitly manage a Java IRunnable identity and its lifetime. The latter is a potential caller-side strategy, not an independently validated workaround in this experiment.

Relevant log output

Source-framework unprofiled repeated-post scenario, batches 5 through 20:

additional operations:                    7500
managed live-byte delta:                  33044000
median managed bytes/additional operation: 4272.112
JNI global-reference delta:               7500
Java live-byte delta:                     602208
native allocated-byte delta:              2991040
weak JNI global references at samples:    0

The paired gcdump type counts and matched single-post/queue-clear controls are summarized above. Raw dumps, traces, and the sample are retained locally but are not attached or publicly linked by this issue.

主要语言
C#
星标
2.1k
派生
579
平均合并
1 天 16 小时
30 天内合并 PR
258

环境准备

  • 没有 Dockerfile 或 Docker Compose 文件
  • 有 Pull Request 模板
  • 没有贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

dotnet/android 的其他 Issue

查看 dotnet/android 的全部 Issue

相似的 Issue

更多 C# Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。