Skip to content

Async Harmony trap

Patching an async method with a Harmony Postfix does not intercept the method's work. The C# compiler rewrites async methods into a state machine; the outer method body Harmony sees is the kick-off stub that returns the awaitable immediately, before any of the asynchronous work has run. A Postfix on that stub fires when the awaitable is returned, not when the work completes, and has no access to any results produced inside the state machine. If the async method also mutates pooled state or writes UI widgets in-flight rather than returning a collection, there is no __result for a Postfix to filter either.

Problem

An async Task or async UniTask method compiles to:

  1. A stub method with the original signature that constructs a state machine, calls MoveNext() once, and returns the awaitable.
  2. A generated state-machine type whose MoveNext() contains the real body split across continuations at each await.

Harmony's attribute path ([HarmonyPatch(typeof(T), "Name")]) resolves and patches the stub. The Postfix therefore:

  • Fires when the awaitable is handed back, not when the async work completes.
  • Cannot read values produced inside the state machine, because those are local to MoveNext frames and no __result exposes them.
  • Cannot block or alter the work that runs after the first await.

This is not a Harmony bug: Harmony is patching exactly the method the attribute names. The trap is that the method the developer thinks they are patching (the full async body) is not the method the compiler actually exposes.

The trap compounds when the async method mutates pooled or UI state in-flight rather than returning a value. There is no ref T __result to edit; the side effects have already landed by the time any observer could see them.

Solution / recipe

Patch a synchronous method that the async body calls, not the async method itself. The compiler's state-machine rewrite only affects the outer method; any synchronous method invoked from inside MoveNext is a normal method. Harmony patches on those synchronous methods run as expected for every call, including calls made from inside async continuations.

Recipe:

  1. Read the async method body. Identify the synchronous helper methods it calls per unit of work (per row, per page, per item).
  2. Pick the helper whose signature exposes the value to filter or the side effect to intercept. A helper that returns bool and is called once per candidate is ideal: a Postfix with ref bool __result can short-circuit downstream work by returning false.
  3. Patch that helper. The Prefix or Postfix runs synchronously inside the async method's continuation, with full access to __instance and arguments.

Verify the helper has few enough callers that patching it does not affect unrelated code paths. A single-caller helper is ideal; when the helper is shared, the Prefix must branch on call site (type of __instance, values of arguments) before acting.

Concrete example

Stationpedia.DoSearch(string hash, string pattern, CancellationTokenSource) is private async UniTask and mutates a pooled _SPDASearchInserts list of UI widgets in-flight. It has no return list and no ref parameter a Postfix could filter. A Postfix on DoSearch therefore cannot remove results.

StationpediaPage.IsRegexMatch(string hash, string pattern) is synchronous, called once per page from inside DoSearch, returns bool, and has exactly one caller in the game. A Postfix with ref bool __result runs inside the async body's MoveNext continuation for every candidate page; returning false short-circuits the MakePage call downstream of the branch.

See ../GameSystems/StationpediaSearch.md for the full trigger chain and why IsRegexMatch is the correct filter target for Stationpedia search.

UniTask's builder runs the first step synchronously

The "calls MoveNext() once" in step 1 above holds for async UniTask<T> as shipped with the game. AsyncUniTaskMethodBuilder<T> in rocketstation_Data/Managed/UniTask.dll, read with ilspycmd -t 'Cysharp.Threading.Tasks.CompilerServices.AsyncUniTaskMethodBuilder1'` at game 0.2.6428.27798:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
[DebuggerHidden]
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
    stateMachine.MoveNext();
}

[DebuggerHidden]
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
}

and each await that does not complete synchronously parks the machine in UniTask's own runner rather than through the state machine's SetStateMachine:

public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine
{
    if (runnerPromise == null)
    {
        AsyncUniTask<TStateMachine, T>.SetStateMachine(ref stateMachine, ref runnerPromise);
    }
    Action moveNext = runnerPromise.MoveNext;
    awaiter.UnsafeOnCompleted(moveNext);
}

Two consequences for a mod that wraps a vanilla UniTask in an async method of its own (the Spray Paint Plus glow side-car does, from a postfix on SaveHelper.Save):

  • The state machine's first MoveNext, and with it the compile of the whole MoveNext, happens synchronously inside the call to the async method, in the caller's frame. A try/catch in the caller therefore sees the async body failing to compile against a changed game (StaleModReferenceJitCrash); later steps run from runnerPromise.MoveNext on code that has already compiled.
  • The generated state machine's own IAsyncStateMachine.SetStateMachine is never reached through this builder: the builder's SetStateMachine is empty and the await path uses AsyncUniTask<TStateMachine, T>.SetStateMachine instead.

Cited verifications

  • F0219g (Plans/StationpediaPlus/PLAN.md:910-924): primary statement of the trap in the context of Stationpedia.DoSearch as an async UniTask at game.cs:230584, and the recipe of patching StationpediaPage.IsRegexMatch (single-caller, bool-returning, synchronous) instead.

Verification history

  • 2026-04-20: page created by lifting the generalized trap from GameSystems/StationpediaSearch.md (F0219g) per Phase 6 Pass B recommendation.
  • 2026-09-21: added "UniTask's builder runs the first step synchronously" at game 0.2.6428.27798, excerpts verbatim from ilspycmd -t of AsyncUniTaskMethodBuilder1in the game'sUniTask.dll`, read during the Spray Paint Plus v1.12.0 entry-point hardening. Additive: it confirms step 1 of "Problem" for UniTask specifically and contradicts nothing; the older sections keep their 0.2.6228.27061 stamps.

Open questions

None at creation.