Skip to main content

Auto-Cancel Lifecycle Binding

One of the most common bugs in Unity async code is launching a task from a MonoBehaviour and then forgetting to cancel it when the object is destroyed. The task continues running, tries to access destroyed Unity objects, and throws MissingReferenceException — or worse, silently corrupts state.

ValkarnTasks eliminates this class of bug through a Roslyn source generator that automatically wires async methods to the object's destroy lifetime.

The Problem

Without any infrastructure, every async method in a MonoBehaviour requires the developer to thread a CancellationToken through manually:

// Manual approach — easy to forget, tedious to maintain
public class EnemyAI : MonoBehaviour
{
CancellationTokenSource _cts;

void OnEnable()
{
_cts = new CancellationTokenSource();
RunPatrolLoop(_cts.Token).Forget();
}

void OnDestroy()
{
_cts?.Cancel();
_cts?.Dispose();
}

async ValkarnTask RunPatrolLoop(CancellationToken ct)
{
while (true)
{
await ValkarnTask.Delay(2000, ct);
MoveToNextWaypoint();
}
}
}

As the number of async methods grows, so does the boilerplate. Skipping OnDestroy — or disposing in the wrong order — causes the leaks described above.

The Generated Approach

Declare your class partial and ValkarnTasks takes care of the rest:

// After — declare partial and the generator does the wiring
public partial class EnemyAI : MonoBehaviour
{
void OnEnable()
{
RunPatrolLoop().Forget();
}

async ValkarnTask RunPatrolLoop()
{
while (true)
{
await ValkarnTask.Delay(2000, __ValkarnTaskLifetimeToken);
MoveToNextWaypoint();
}
}
}

No CancellationTokenSource, no OnDestroy, no disposal. The generated token is cancelled automatically when Unity destroys the object.

How the Source Generator Works

The generator (LifecycleBindingGenerator) is an incremental Roslyn generator that runs at compile time. Its pipeline has three stages.

Stage 1 — Syntax filter

The generator examines every class declaration in your project. A class is considered a candidate if:

  • It is declared with the partial keyword.
  • It has a base class list (i.e., it inherits from something).

This filter is purely syntactic and very fast. No semantic analysis runs at this stage.

Stage 2 — Semantic transformation

For each candidate class, the generator uses the Roslyn semantic model to:

  1. Confirm the class derives from UnityEngine.MonoBehaviour (walks the full inheritance chain).
  2. Enumerate all members. For each member, it checks whether it is:
    • An async method.
    • Returns UnaPartidaMas.Valkarn.Tasks.ValkarnTask (or ValkarnTask<T>).
    • Does not carry [NoAutoCancel].
  3. If no qualifying methods are found, the class is silently skipped — nothing is generated.
  4. Only the first partial declaration is processed. If a class is split across multiple files, the generator emits code once, tied to the first declaration, to avoid duplicate members.

Stage 3 — Code emission

For each class that passes stages 1 and 2, the generator writes a new .g.cs file. The generated code for a class named EnemyAI in namespace Game.Enemies looks like this:

// <auto-generated/>
#nullable disable
using System.Threading;

namespace Game.Enemies
{
partial class EnemyAI
{
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
CancellationTokenSource __valkarnTaskLifetimeCts;

/// <summary>
/// Cancellation token that is triggered when this MonoBehaviour is destroyed.
/// Auto-generated by ValkarnTask source generator.
/// </summary>
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
protected CancellationToken __ValkarnTaskLifetimeToken
{
get
{
if (__valkarnTaskLifetimeCts == null)
__valkarnTaskLifetimeCts =
CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
return __valkarnTaskLifetimeCts.Token;
}
}
}
}

Key details:

  • The CancellationTokenSource is lazy — allocated only the first time __ValkarnTaskLifetimeToken is accessed.
  • It is linked to Unity's built-in destroyCancellationToken (MonoBehaviour.destroyCancellationToken, available since Unity 2022). When Unity destroys the object, destroyCancellationToken fires, which cascades to __valkarnTaskLifetimeCts, which cancels __ValkarnTaskLifetimeToken.
  • Both the field and the property are marked EditorBrowsable(Never) so they do not pollute IntelliSense for users of the class.
  • The property is protected, so subclasses can also use the same token.

The [NoAutoCancel] Attribute

Apply [NoAutoCancel] to any async ValkarnTask method when you intentionally want it to continue running beyond the object's lifetime. Common scenarios:

  • A method that saves data to disk and must complete even if the triggering object is destroyed.
  • A method managing a shared resource owned by a different system.
  • Transition effects that intentionally outlive the object that started them.
public partial class SaveManager : MonoBehaviour
{
// This method WILL be auto-cancelled on destroy
async ValkarnTask AutoSaveLoop()
{
while (true)
{
await ValkarnTask.Delay(30_000, __ValkarnTaskLifetimeToken);
await WriteSaveToDisk();
}
}

// This method will NOT be auto-cancelled — it must finish writing
[NoAutoCancel]
async ValkarnTask WriteSaveToDisk(CancellationToken ct = default)
{
await FileSystem.WriteAsync(_saveData, ct);
}
}

[NoAutoCancel] is a method-level attribute. The generator simply excludes that method from its qualifying method count. If all methods in a class carry [NoAutoCancel], the generator emits nothing for that class.

Analyzer: TT014 — NoAutoCancel without a CancellationToken parameter

A companion analyzer (NoAutoCancelAnalyzer) reports diagnostic TT014 when you apply [NoAutoCancel] to a method that has no CancellationToken parameter. If there is no token parameter, the method has no way to observe cancellation — meaning [NoAutoCancel] is present but has no practical effect. This usually means you forgot to add the token:

// TT014: [NoAutoCancel] applied but method has no CancellationToken parameter
[NoAutoCancel]
async ValkarnTask WriteSaveToDisk() // <-- missing ct parameter
{
await FileSystem.WriteAsync(_saveData);
}

Fix by adding a CancellationToken parameter:

[NoAutoCancel]
async ValkarnTask WriteSaveToDisk(CancellationToken ct = default)
{
await FileSystem.WriteAsync(_saveData, ct);
}

The [FireAndForget] Attribute

[FireAndForget] is a separate, complementary attribute that marks an async method as intentionally not awaited. It serves two purposes:

  1. Suppresses warnings VTASKS-TASK002 and VTASKS-TASK013, which fire when callers do not await a ValkarnTask return value.
  2. Signals intent — future readers of the code know the discard is deliberate.

The source generator wraps [FireAndForget] methods to ensure any unobserved exceptions are published through ValkarnTasks' unobserved-exception handler rather than being silently lost.

public partial class SpawnManager : MonoBehaviour
{
void OnPlayerDied()
{
// No warning, intent is clear
ShowDeathScreenAsync();
}

[FireAndForget]
async ValkarnTask ShowDeathScreenAsync()
{
await ValkarnTask.Delay(500, __ValkarnTaskLifetimeToken);
_deathScreen.SetActive(true);
}
}

[FireAndForget] and [NoAutoCancel] are independent and can be combined:

[FireAndForget]
[NoAutoCancel]
async ValkarnTask PlayGlobalMusicAsync(CancellationToken ct = default) { ... }

Analyzer: TT010 — Auto-Cancel Active

The AutoCancelInfoAnalyzer reports an informational diagnostic TT010 on every async ValkarnTask method in a MonoBehaviour that will be auto-cancelled (i.e., does not have [NoAutoCancel]). This is not an error or warning — it is intentional transparency so developers can see at a glance which methods are lifecycle-bound.

You can suppress TT010 per-method with [NoAutoCancel], or disable it project-wide via .editorconfig if you prefer not to see it.

Limitations

The class must be declared partial. The source generator cannot add members to a non-partial class. If your MonoBehaviour is not partial, the generator silently skips it and no binding is created. The [NoAutoCancel] and [FireAndForget] attributes still work as documentation and for the analyzers, but __ValkarnTaskLifetimeToken will not be available.

Nested classes. If a MonoBehaviour is declared as a nested class inside another class, both the outer and inner class declarations must be partial. Roslyn requires all enclosing types to be partial for generated members to compile correctly.

Base classes. The generated __ValkarnTaskLifetimeToken property is protected. Subclasses automatically inherit access to it. The generator runs for each class in the hierarchy independently; if both a base class and a derived class are partial MonoBehaviours with async methods, each gets its own generated partial, but they share the same underlying token because destroyCancellationToken is inherited from the MonoBehaviour base.

Multiple inheritance. C# does not support multiple inheritance of classes. A MonoBehaviour can only have one class base, so there is no ambiguity about which destroyCancellationToken to link.

ScriptableObjects. The generator currently targets MonoBehaviour only. ScriptableObject does not have a destroyCancellationToken equivalent in the Unity API, so auto-cancel generation is not available for them.