devinside.Marcin Borecki Ideas. Systems. Evidence.
← The notebook

Engineering

Allocation or Retention: Follow the Reference That Still Owns the Payload

A real heap-root investigation separates allocation history, retained ownership and process residency, then verifies the removal of an event subscription.

Explore the mechanism ↓
Editorial ink and watercolor illustration for The payload survives through an ownership edge.

START WITH THE MECHANISM

Interactive companion and laboratory update: 12 November 2026. The editorial date places the topic on the timeline; this current explanation and its measured evidence retain their actual preparation and execution dates.

THE FULL INVESTIGATION

Allocation or Retention: Follow the Reference That Still Owns the Payload

Editorial context: 9 October 2025 · Prepared and updated: 12 November 2026
Dates and technology context

This is a retrospective editorial timeline. Topic context: 9 October 2025. First publicly available: 12 November 2026. Current text, visuals and laboratory revisions were prepared on 12 November 2026.

Every .NET11 dump, object address, retaining path, 16MiB fixture observation and dotnet-dump10.0.750501 command is a 2026-11-12 update, never a 2025 dump. The actual .NET11 evidence is not available before 2026-11-10.

A memory graph rises, an operation has already been cancelled, and somebody asks why the garbage collector has not cleaned up. That question starts too late in the ownership story. The collector does not know that a business operation is finished. It knows whether the object graph remains reachable.

The distinction is easy to describe and surprisingly easy to lose during an investigation. Allocated bytes, live objects and process working set can all move differently. A successful cancellation does not automatically remove an event subscription. A dump can identify a retaining path without proving that the path violates the application's intended lifetime. A forced collection can reveal reachability without fixing it.

This article follows one actual, deliberately small .NET experiment. A completed cancellation path leaves a sixteen-mebibyte payload attached to a long-lived event publisher. We collect a heap dump, inspect the retaining references, remove the subscription and collect a second dump. The payload disappears from the managed heap while the process working set grows. Both observations are real, and neither contradicts the other.

The laboratory ran on 12 November 2026 using SDK 11.0.100, runtime 11.0.0 and a locally installed dotnet-dump 10.0.750501. It uses synthetic data and its own process only. The purpose is to build a defensible explanation from a small object graph, not to simulate a production outage or claim a universal memory-saving percentage.

Name the lifetime that should have ended

The fixture models a short operation with a payload and a callback subscription. It creates a PayloadLease object, subscribes its OnTick method to a publisher and then observes a cancelled task. The setup method returns. At that point, the operation's work has ended, but the publisher still holds the delegate.

This is intentionally a narrow cancellation example. The token is cancelled before the delay is awaited, and the cancellation exception is handled. There is no abandoned background task that continues consuming memory in this fixture. The retained object is explained by the event subscription, not by an unobserved async state machine or an unfinished I/O operation.

That choice makes the diagnosis sharper. If a real service contains both a pending task and an event subscription, either may contribute to reachability. Starting with a case where the task has completed removes one competing explanation. The investigation can then ask whether the event owner still has a path to the payload after the operation's intended lifetime.

The publisher is stored in a static field. Its lifetime is therefore longer than the setup method and the local lease variable. The method creates a WeakReference to the lease for the test, but that weak reference is not supposed to keep the target alive. The event delegate is the strong connection we expect to find in the dump.

An event subscription is a relationship between two lifetimes. If a short-lived subscriber attaches to a long-lived publisher, the subscription needs a corresponding end condition. C# event documentation explains that the publishing object retains the subscriber's handler until it is removed. The laboratory demonstrates that relationship rather than treating it as an allocation-rate problem. Microsoft: subscribing and unsubscribing from events

Calling this retention a bug depends on the contract. A subscriber intended to live for the application's lifetime could legitimately retain its state. In this fixture, the declared operation has ended and the payload is no longer useful, so the continuing ownership is the defect being demonstrated. The dump supplies the reference path; the lifetime contract supplies the reason to change it.

Keep the object graph small enough to explain

The payload owner contains one byte array. Its length is exactly 16,777,216 bytes, or sixteen mebibytes. The constructor touches the first and last elements with fixed values. No customer document, token, connection string or real service response is placed in the array.

The actual classes are deliberately uncomplicated:

sealed class Publisher
{
 public event Action? Tick;
 public int SubscriberCount=>Tick?.GetInvocationList().Length??0;
 public void Clear()=>Tick=null;
}
sealed class PayloadLease
{
 public readonly byte[] Payload=new byte[16*1024*1024];
 public PayloadLease(){Payload[0]=17;Payload[^1]=29;}
 public void OnTick(){GC.KeepAlive(Payload);}
}

The event is never raised during the experiment. Its significance is ownership, not callback execution. A delegate can keep its target alive without being invoked. This is one reason a quiet feature can retain memory even when its CPU cost is negligible and its logs show no recent activity.

The setup method is marked NoInlining and returns before collection. This reduces the risk that the local variable's implementation lifetime becomes the explanation for survival. It does not replace the dump: the actual retaining path still has to be inspected. Compiler and runtime behavior are inputs to the experiment, not assumptions to hide behind a convenient attribute.

The publisher's Clear method removes all handlers from this laboratory-owned event. That is suitable here because there is exactly one subscriber and the publisher is entirely owned by the fixture. It is not a recommendation to erase unrelated subscribers in a shared production event source. A real repair normally removes the specific subscription that the operation owns.

The fixture could have used an anonymous lambda capturing a payload instead. That would introduce a closure object and another ownership edge. The simpler named-method version makes the delegate target visible without requiring a separate explanation of compiler-generated closure classes. The broader lesson still applies: indirect references count, even when source code does not show a direct publisher-to-array field.

Separate allocation history from current reachability

01 / See the idea

Three questions hidden in one memory graph

Allocation history, reachability and working set are three separate observations.

The selected array can be reclaimed without reversing its historical allocation. Process residency includes a broader set of pages.

Interpretation and evidence
  • Allocation is a flow.
  • Reachability identifies current ownership.
  • Working set is a process boundary.
Conceptual explanation unless labelled measured; counts and bytes derived from saved lab evidence.

The program records three memory-related observations: allocated bytes since setup, GC.GetTotalMemory(false) and the process working set. These measurements have different meanings and different boundaries. They should not be placed on one chart as though they were three estimates of the same quantity.

Allocated bytes describe creation over an interval. Reclaiming an object does not subtract its earlier allocation from that historical counter. A program can allocate a large amount over its lifetime while keeping only a small live graph. It can also allocate slowly while retaining objects for much longer than intended.

The managed-memory observation concerns the managed heap as reported at that point. It includes more than the selected payload. Runtime objects, weak-reference infrastructure and the laboratory's own serialization can contribute. The array's exact size is established separately by the heap analysis rather than inferred by subtracting two noisy totals.

Working set is a process-level observation. It can include pages unrelated to the selected managed object, and diagnostic activity can change which pages are resident. The operating system and runtime do not promise to reduce this value immediately when one object becomes unreachable. The dump tool's documentation also notes that collection can cause memory to be paged into the target process. Microsoft: dotnet-dump

The garbage collector reasons from roots and reachable object references. Static fields, handles and thread state can participate in that graph. An object that remains reachable is not garbage merely because a product feature has finished using it. Microsoft: fundamentals of garbage collection

This distinction changes the first diagnostic question. If the concern is allocation pressure, investigate how frequently and where objects are created. If the concern is unexpected survival, investigate who still owns them. If the concern is process residency or a container limit, include the broader memory boundary. A single total cannot answer all three questions.

Collect evidence at a deliberate observation point

After setup returns, the fixture performs a forced collection, records its memory observations and writes a readiness file. It then waits for a command from the wrapper. The wrapper knows the exact child process ID it created and collects a heap dump from that process. It never searches for or attaches to another application.

The forced collection is a laboratory control. Its purpose is to give unreachable objects an explicit opportunity to be reclaimed before we inspect the retained graph. It is not presented as a production repair or a way to make the garbage collector understand application intent. If a strong path remains, collecting repeatedly does not remove that path.

The child receives a restricted environment containing operating-system and runtime paths rather than inheriting application credentials. Its payload is synthetic, and the dumps remain local evidence. This keeps the diagnostic artifact within the intended demonstration boundary. A dump from an arbitrary production process would require a different data-handling decision.

The wrapper bounds each collection and analysis command, and the subject has its own timeout while waiting for control files. After both observation points, it is told to exit. If the wrapper fails, its finally path closes its owned child process. The recorded execution confirms exit code zero and process closure.

The dump type is Heap. That selection captures the managed state required for root analysis, and it still produces a substantial artifact. The raw files are preserved with SHA-256 hashes so the textual reports can be tied to the specific captured states. The article's figures summarize those reports; they do not replace them.

A readiness file is a protocol boundary, not proof of a perfectly frozen application. In this fixture, the waiting code is intentionally quiet and the selected ownership graph is stable. In a busy service, capturing a dump at an arbitrary moment can mix many concurrent lifetimes. A useful reproduction reduces that ambiguity before drawing a retention conclusion.

Follow the actual path from the root to the array

02 / A concrete example

Follow the actual retaining fields

Program.Events refers to Publisher, whose event delegate targets PayloadLease and its 16MiB array.

The field values agree on the same object addresses. The raw tool output also contains a misleading static-label annotation, which is retained and discussed.

Interpretation and evidence
  • Check field values, not only labels.
  • A delegate target is an owner.
  • Keep the raw root report.
Conceptual explanation unless labelled measured; counts and bytes derived from saved lab evidence.

The first heap listing finds one PayloadLease object, one Publisher and one large byte array. The array has 16,777,216 elements and an object size of 16,777,240 bytes in this execution. The additional bytes are part of the object's representation; the logical payload length remains sixteen mebibytes.

The gcroot result shows a strong handle leading through a System.Object[] to Publisher, then System.Action, then PayloadLease. Following the lease's Payload field reaches the large byte array. The evidence therefore contains a concrete path, not just a count of arrays that happen to be large.

Further object inspection confirms the individual links. Publisher.Tick points to the Action object. The delegate's target field points to PayloadLease. The lease's Payload field points to the array. The Program type's static Events field points to that same Publisher instance. These matching object addresses make the ownership explanation testable.

There was an important tooling irregularity. The gcroot output annotated the publisher edge with an unrelated-looking static field label, System.String.s_systemDirectory. That annotation is retained in the raw output. It is not used as the explanation. Inspecting Program's static fields independently shows Events pointing to the publisher's actual address.

This is a useful reminder that a diagnostic tool's human-readable label is not always the strongest piece of evidence. The reference chain, object types, field values and controlled source change agree here. Rather than silently rewriting the tool's output or repeating its questionable label, the article documents the discrepancy and uses the corroborating field inspection.

The root path also tells us what not to blame. There is no evidence in this graph that the array is retained by a pending Task continuation. The cancellation path has completed. Calling the result an async state-machine leak would misidentify the owner and encourage the wrong repair. Async code surrounds the example, but the retaining mechanism is the event delegate.

Remove the ownership edge, then inspect again

03 / Watch the mechanism

Change one owner and inspect again

Removing the publisher event edge allows the lease and array to become unreachable while the publisher remains.
Enlarge diagram

Static view. Play to see how the mechanism unfolds.

The marker follows the test sequence, not collector speed. The fixed labels preserve the evidence when motion stops.

Interpretation and evidence
  • Do not equate restart with diagnosis.
  • Remove the owned subscription.
  • Verify the selected object disappeared.
Conceptual explanation unless labelled measured; counts and bytes derived from saved lab evidence.

The wrapper writes a release command. The subject clears the event, performs the same collection sequence, records another snapshot and waits at the second observation point. The publisher itself stays alive. The experiment changes the subscription edge rather than destroying the entire process and declaring victory.

That distinction matters. Restarting a service releases all its process memory, but it does not establish which ownership relationship caused the original retention. Keeping the publisher and process alive allows the second dump to test the narrower hypothesis: removing the handler should make the lease and its payload unreachable.

The second heap report still finds Publisher. It no longer finds PayloadLease, and the large-array query finds no matching object. The WeakReference reports that the target is no longer alive, and the subscriber count is zero. These independent observations agree with the predicted effect of removing the event edge.

The fixture does not claim to have proven every subscription pattern correct. It has one publisher, one subscriber and no concurrent event invocation. A real event source can race with disposal or deliver callbacks already captured by another thread. Such behavior needs its own lifetime contract and tests. Removing a delegate from a list is not automatically a guarantee that no callback is currently executing.

The implementation also does not use a finalizer as the cleanup mechanism. Depending on finalization to unsubscribe from a publisher that keeps the subscriber alive would miss the ownership problem. Cleanup must occur at an application boundary that can actually be reached while the publisher still owns the subscriber.

In a richer design, a subscription token or explicit disposal scope can make that boundary easier to express. Those are design alternatives, not additional implementations benchmarked here. The executed repair is the smallest ownership change needed to test the hypothesis, with the original and repaired states preserved in two dumps.

Read the measured memory table without flattening its meaning

04 / A concrete example

The counters move in different directions

Managed bytes fell, while cumulative allocations and working set rose after the selected payload was released.

The heap evidence establishes the payload lifetime directly. The broader counters describe different boundaries and cannot replace that evidence.

Interpretation and evidence
  • Use exact object evidence for size.
  • Cumulative allocation does not fall.
  • Resident pages need not fall immediately.
Conceptual explanation unless labelled measured; counts and bytes derived from saved lab evidence.

The recorded values are exact byte counts from this run:

Observation Subscription retained Subscription removed
Subscriber count 1 0
Weak target alive true false
PayloadLease objects in dump 1 0
Selected array object bytes in dump 16,777,240 0
Managed bytes reported by the subject 16,801,856 88,016
Allocated bytes since setup 16,784,752 16,910,712
Process working set bytes 19,972,096 30,208,000

The live selected graph disappears, and the managed-memory observation falls sharply. The allocation counter rises because more objects were created while the process continued. The working set also rises. If we had used working set alone as the repair criterion, we could have rejected a change that demonstrably removed the unintended managed owner.

The table does not establish why each additional resident page appeared. Dump collection, code execution, serialization and runtime behavior all occurred between observations. The experiment was designed to identify a retaining path, not to attribute every page of process residency. The appropriate conclusion is that working set did not track the selected object's lifetime in this run.

Likewise, subtracting the managed totals is not a precise object-size calculation. The dump provides the array's size directly. The totals describe the surrounding process state and include other managed allocations. Use the most direct evidence available for each claim rather than making one counter carry every explanation.

This is not a throughput benchmark. No latency distribution or allocation-per-request efficiency claim is derived from these two snapshots. They are before-and-after observations of a controlled ownership transition. That narrower experiment is sufficient for the retention question and avoids adding artificial load merely to make the result look more substantial.

Test the hypothesis that would otherwise be convenient

05 / A concrete example

Test the ownership hypothesis directly

The first dump contains one lease and its array; the second contains neither, after the event edge is removed.

The experiment tests whether the publisher is the remaining owner. It does not need a large repeated allocation load to establish that mechanism.

Interpretation and evidence
  • Define the intended lifetime.
  • Retain the original dump.
  • Do not infer a universal leak rate.
Conceptual explanation unless labelled measured; counts and bytes derived from saved lab evidence.

The convenient story is that cancellation failed to free memory. The more accurate story is that cancellation completed while another owner remained. The experiment separates those statements by ending the task path before capturing the retained state and by identifying the event delegate in the root graph.

Another convenient story is that the collector needed encouragement. Forced collection occurred before both snapshots. The object survived when the subscription existed and disappeared when the subscription was removed. The changed input was ownership, not a newly discovered magic collection call.

A third convenient story is that the process still looks large, so the repair did not work. The second dump and weak-reference check reject that conclusion for the selected graph. They do not promise that process memory will return to an earlier value or that the application has no other retained objects. A precise repair result can coexist with unanswered questions at another boundary.

Good tests protect these distinctions. The subject should fail if the lease survives after release. The analysis should fail if the expected retained object or reference path is absent. The wrapper should fail if its child does not exit. Each check corresponds to a claim the article actually makes.

For a production adaptation, define the drain condition before writing the test. Has the operation finished? Have callbacks stopped? Have cancellation registrations been disposed? Have queues relinquished their references? A generic wait followed by a memory-total assertion is weaker than a test tied to those ownership transitions.

The fixture's single instance also avoids claiming that one observed path proves an unbounded leak. Repeatedly creating subscribers without removing them could produce growth, but this run does not need to manufacture that growth to establish the retaining mechanism. One unexpectedly surviving payload and a verified owner are enough to motivate the repair.

Choose a repair boundary that survives failure

The demonstration clears the event from the publisher because the fixture owns the entire object. A reusable component usually needs a more selective contract. The operation that subscribes should retain enough information to remove exactly that handler, and its cleanup should execute whether the operation succeeds, fails or is cancelled.

That does not mean sprinkling unsubscribe calls around every return statement. Scattered cleanup is easy to miss when another branch is added. A lifetime scope with one well-defined end point makes the obligation easier to review. If ownership moves to another component, the transfer should be explicit: the old owner must not assume cleanup happened merely because it returned a Task.

The callback's behavior also matters. If the publisher can invoke handlers concurrently with cleanup, removing the subscription prevents future lookup through that event list but may not cancel an invocation already obtained by another thread. A component that promises no callbacks after disposal needs a synchronization or completion protocol beyond the simple fixture shown here.

A weak subscription is another possible design, but it changes the contract rather than mechanically fixing every leak. If the subscriber can disappear whenever no other owner exists, the publisher cannot rely on the callback remaining available. That may be acceptable for optional notifications and unacceptable for work whose completion somebody depends on. Choosing weak ownership without defining that behavior can replace memory retention with missing work.

The best repair therefore starts with the question of responsibility. Who promises that the callback exists? Who decides that the promise has ended? Who waits for an in-flight callback, if waiting is required? The current experiment answers only the first-order retention question, but it exposes the exact edge where these design decisions belong.

An operational fix can be smaller than a general subscription framework. A single owned handler removed in a finally block may be enough. The evidence should determine the scale of the change. Introducing an elaborate abstraction without a demonstrated lifetime problem can make ownership harder to see instead of easier.

Keep diagnostic identities tied to their artifact

The addresses printed in the heap reports identify objects within the captured process state. They are not stable application identifiers and should not be copied into a reproduction command as though another run will allocate objects at the same locations. The wrapper first enumerates the relevant type, then uses the discovered address for root analysis.

The additional field report in this bundle targets addresses from the preserved retained dump. Its role is to corroborate that particular artifact, including the questionable static-field annotation. A fresh run should discover its own addresses and method table before following the same inspection sequence.

This is a small but important provenance rule. A successful command against the wrong dump or an address reused by another object can produce a plausible report that answers a different question. Preserve the dump name, hash, command and exit status together. When sharing a screenshot or diagram, retain a route back to those inputs.

That route is especially useful when tools and runtimes evolve. A later analyzer may improve labels or expose more fields, but it should not erase the original observation. Keeping the artifact permits reinterpretation while keeping the executed experiment stable. The result is an investigation that can be checked, not merely a conclusion that must be trusted.

Transfer the method to async services carefully

In a real asynchronous service, a retained payload may be owned by a pending task, a channel item, a timer callback, a cancellation registration, a cache or an event publisher. These are different lifetime mechanisms. The fact that all of them can appear near async code does not make them one category of defect.

Start with the operation's expected end condition and the evidence that it actually reached that condition. Then locate the object of interest and follow its roots. If the path runs through a queued message, investigate why the queue still owns it. If it runs through a task, inspect whether the task is incomplete or whether completed work is retained elsewhere. If it runs through a delegate, identify the publisher and subscription scope.

Do not remove every strong reference simply because a dump shows one. Caches and queues intentionally retain data. The question is whether the retained graph matches a declared responsibility and bound. A cache without a bound can be a problem even when every entry is technically reachable by design; an active operation can legitimately retain a large payload until completion.

The method also suggests a useful order of work. Establish the symptom with appropriately scoped telemetry. Form a specific lifetime hypothesis. Capture evidence at a meaningful state. Inspect the retaining path. Change one ownership edge. Repeat the relevant check while preserving the original artifact. This sequence makes the repair reviewable without pretending that a general memory graph supplies a causal explanation on its own.

Finally, distinguish a local proof from a deployment claim. The laboratory proves that this event subscription retained this payload and that removing it allowed collection in this runtime execution. It does not prove the absence of all leaks, the optimality of a GC configuration or the safety of collecting dumps under production memory limits. Those questions remain separate because their evidence is separate.

The practical result is a more useful vocabulary for memory incidents. Allocation tells us that objects were created. Reachability tells us why selected objects survive. Working set tells us something about process residency. Ownership tells us whether that survival still serves the application. The best diagnosis connects those views while refusing to substitute one for another.

Source code and execution evidence

Download the complete lab, source record and raw execution results. This package supports the specific execution claims in this article. It is not a production certification.

THE AUTHOR

Marcin Borecki

Developer, entrepreneur and business angel. Writing about AI, software, data and the evidence behind technical decisions.

About the author →