[net11.0-android] Handler.RemoveCallbacks(Action) leaves earlier repeated posts queued, retaining managed objects and JNI references
维护者通常 1 天内回复
评估
调研方向
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 内容生成。
描述
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
- Create a plain
net11.0-androidapp, withSupportedOSPlatformVersionat least24. No MAUI or external dependencies are required. - Add the following helper and invoke
await Task.Run(() => RunAsync());from the activity while keeping the app visible and device awake. - Run with
repeat: true. Restart the app process and repeat withrepeat: false. Optionally repeat withclearQueue: true. - 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 模板
- 没有贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
dotnet/android 的其他 Issue
-
Area: App+Library Build
难度 2/5 1-3 小时 新手友好度 72/100
维护者通常 1 天内回复
-
Area: Mono.Android
难度 2/5 1-3 小时 新手友好度 65/100
dotnet/android#9192 · 4 条评论 · 1 个 reaction ·
维护者通常 1 天内回复
-
Area: Mono.Bindings needs-triage
难度 4/5 3-5 天 新手友好度 48/100
维护者通常 1 天内回复
-
needs-triage
难度 4/5 3-5 天 新手友好度 48/100
维护者通常 1 天内回复
-
agentic-workflows needs-triage
难度 3/5 1-2 天 新手友好度 45/100
dotnet/android#13325 · 1 条评论 ·
维护者通常 1 天内回复
相似的 Issue
-
难度 2/5 1-3 小时 新手友好度 66/100
MahdiyarGHD/GheychiApp#39 ·
维护者通常 1 天内回复
-
bug effort:S P3
难度 2/5 1-3 小时 新手友好度 76/100
nightscout/nocturne#3136 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
-
难度 2/5 1-3 小时 新手友好度 67/100
维护者通常 1 天内回复
-
难度 1/5 1 小时以内 新手友好度 76/100
GameShowLDA/AskMkiM#1075 ·
维护者通常 1 天内回复