Skip to content

Continuation cloning binds to an object local instead of the awaiter field #39

Description

@jhorv

StateMachineEff.ReflectionHelpers locates the state machine's awaiter field by type alone:

// src/Eff/Builders/StateMachineEff.cs
private static readonly FieldInfo? s_smObject = typeof(TStateMachine)
        .GetFields(BindingFlags.Instance | BindingFlags.NonPublic)
        .Where(f => f.FieldType == typeof(object))
        .FirstOrDefault();

Roslyn types the awaiter field as object when the awaiter is a reference type, reusing one slot for all such awaiters, so normally <>u__1 is the only candidate and this works. But a hoisted local declared object is also an object-typed field, and Roslyn emits hoisted locals ahead of its own bookkeeping fields — so FirstOrDefault() binds to the user's local.

Fields of a real state machine, identical in Debug and Release:

priv Object   <marker>5__1      <- s_smObject binds here
priv Object   <>u__1            <- the awaiter field it wanted

UnsafeSetAwaiter then writes the awaiter over the local and never updates the awaiter field, so the clone resumes against the original, already-completed awaiter.

Repro

static async Eff<int> Test()
{
    object marker = "ab";                          // `object`, live across the await
    int x = await NonDetEffect.Choose(1, 2, 3);
    return x + marker.ToString()!.Length;
}

await NonDetEffectHandler.Run(Test());   // expected [3, 4, 5]
InvalidOperationException: Awaiter of type NonDetEffect`1 has not been completed.

Declaring marker as string instead gives the correct [3, 4, 5]. Reproduced in both Debug and Release, so it is not specific to class- or struct-shaped state machines.

Impact

Any handler that clones continuations — non-determinism and call/cc, including Eff.Examples.NonDeterminism and Eff.Examples.Continuation — breaks for any eff method holding a local of type exactly object across an await: an object local, a dynamic local, or the iteration variable of a non-generic foreach. EffectHandler never clones and is unaffected.

The exception reads as handler misuse rather than a library defect, which makes it easy to misattribute.

Fix

Match on Roslyn's field naming as well as the type:

  private static readonly FieldInfo? s_smObject = typeof(TStateMachine)
          .GetFields(BindingFlags.Instance | BindingFlags.NonPublic)
+         .Where(f => f.Name.StartsWith("<>u__", StringComparison.Ordinal))
          .Where(f => f.FieldType == typeof(object))
          .FirstOrDefault();

The adjacent s_smBuilder lookup has the same type-only ambiguity — reachable only via an eff method taking an EffMethodBuilder<T> parameter, so theoretical, but worth the same treatment (<>t__).

Verified on a fork: the repro passes in both configurations and the full suite stays green. Removing the name filter again fails exactly the new regression test and nothing else. The names are compiler implementation details with no documented contract — the same exposure Utils/TraceHelpers.cs already relies on.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions