Skip to content

Chains inside doubly-nested lambdas emit interceptors that fail to compile (CS0103 on captured locals) #333

Description

@DJGosnell

Description

A Quarry chain written inside a lambda that is itself nested in another lambda makes the generator
emit an interceptor that references the enclosing method's locals directly. Those locals live in a
compiler-generated display class the interceptor cannot see, so the generated file fails to compile:

CS0103: The name 'name' does not exist in the current context

in *.Interceptors.*.g.cs. It is a build break with no Quarry diagnostic — the first sign of trouble
is a compiler error inside generated code.

Found while writing the concurrency suite for #314. The natural way to express a parallel worker is:

var tasks = harnesses.Select((h, i) => Task.Run(async () =>
{
    var name = $"Worker{i}";                 // local in the inner lambda's display class
    await h.Lite.Users()
        .Update()
        .Set(u => u.UserName = name)         // captured by the chain
        .Where(u => u.UserId == 1)
        .ExecuteNonQueryAsync();
}));

The emitted interceptor for that chain refers to name as though it were in scope at the
interception site. It is not — it is a field on the display class the two nested lambdas share.

Location

Generator: display-class resolution for captured variables —
src/Quarry.Generator/Parsing/DisplayClassEnricher.cs and
src/Quarry.Generator/Parsing/DisplayClassNameResolver.cs.

Workaround in the test suite: src/Quarry.Tests/Integration/ConcurrencyTests.cs. Every worker body
is a named private static async Task<T> Run…WorkerAsync(...) method rather than an inline lambda,
so the chain's captures are ordinary method locals. The fixture <remarks> records why.

Diagnostics

CS0103 in the generated interceptor file, reported for all four fixture contexts
(TestDbContext, PgDb, MyDb, SsDb) when the shape is present. EmitCompilerGeneratedFiles
is already enabled on Quarry.Tests.csproj, so the offending generated source is inspectable under
obj/GeneratedFiles/Quarry.Generator/Quarry.Generators.QuarryGenerator/.

What Has Been Tried

  • Confirmed the nesting is what matters, not the parallelism. The same chain hoisted into a named
    method compiles and runs correctly; that is the workaround now in ConcurrencyTests.
  • Not yet isolated to a minimal repro. The variables worth bisecting, none of which have been
    tested in isolation:
    • one lambda vs. two (does a single non-nested lambda already fail?);
    • async lambda vs. plain lambda;
    • a captured loop variable (i) vs. a plain local declared inside the inner lambda;
    • Task.Run(...) specifically vs. any nested lambda;
    • whether it reproduces without Select((h, i) => ...) supplying an index.

This is the main thing the issue needs before a fix: a synthetic case in
src/Quarry.Tests/Generation/ that fails, so the fix has a test.

Gathered Information

  • The generator predicts compiler-generated closure class names to emit [UnsafeAccessor] methods
    for captured-variable extraction without reflection — see the "Display Class Prediction" section of
    src/Quarry.Generator/llm.md. Nested lambdas produce nested/chained display classes, and the
    prediction is the natural suspect.
  • Related but distinct: Display-class prediction robustness: stale UnsafeAccessorType names on cross-partial ordinal shifts; generic containing types/methods unguarded (runtime TypeLoadException) #310 covers display-class prediction robustness for cross-partial ordinal
    shifts
    and generic containing types. This is a different trigger (lambda nesting depth) and fails
    at compile time in the generated file rather than at runtime with a TypeLoadException, so it is
    filed separately rather than folded in.
  • The chain analyzer already disqualifies some closure shapes outright (Chain variable captured in lambdaOptimizationTier.RuntimeBuild + QRY032). This shape is not disqualified — it is
    accepted and then emitted wrongly, which is why it surfaces as CS0103 rather than a Quarry
    diagnostic.

Suggested Approach

Two directions, in order of preference:

  1. Resolve the capture correctly. Teach display-class resolution to walk the nested-lambda chain
    so the interceptor reads the local through the display-class instance it actually lives on, the
    same way a single-level capture already works.
  2. Fail loudly if (1) is out of scope for now. Detect the nested-lambda-with-captures shape in
    ChainAnalyzer.CheckDisqualifiers and disqualify it to RuntimeBuild with a QRY diagnostic
    naming the shape and the "hoist the chain into a named method" workaround. A build error inside
    generated code is the worst possible way for a user to meet this limitation; a QRY error at the
    call site is the least surprising.

Either way, add the minimal repro from "What Has Been Tried" to
src/Quarry.Tests/Generation/ so the behaviour is pinned.

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