Skip to content

Own.Concurrent: retrofit structured-concurrency contracts onto existing C# async code #282

Description

@PhysShell

Classification

  • Type: enhancement / research-to-product proposal
  • Priority: P2 / horizon
  • Expected value: high if the existing ownership core and Own.Async fact extraction prove useful on real C#
  • Effort: high
  • Risk: high semantic and false-positive risk if attempted before the narrower async slices
  • Recommendation: record now; implement only after the first Own.Async diagnostics and real-C# task facts are stable

Summary

Explore an Own.Concurrent profile that retrofits structured-concurrency contracts onto ordinary C# without requiring migration to a new language.

The key model is:

child task = owned outstanding obligation
await / join / approved transfer = discharge
lexical operation / request / component = lifetime region
cancellation token = capability inherited from the parent scope
captured resource must outlive the child task

This should remain an Own.NET-shaped feature: Roslyn extracts task/scope/capture facts, the existing core decides, and findings carry explicit evidence. It is not a second async verdict engine and not a promise to reconstruct the CLR scheduler.

Motivation

C# permits unstructured task lifetimes:

  • child tasks can silently outlive the operation that created them;
  • sibling tasks can become unobserved when an earlier sequential await faults;
  • Task.WhenAny can leave losing tasks running with no cancellation/join policy;
  • fire-and-forget work often has no explicit supervisor;
  • cancellation is accepted by a parent operation but not propagated to children;
  • tasks can capture a scoped service, stream, DI scope, or UI component that is disposed before the task completes.

These are naturally expressible as ownership/lifetime/obligation failures rather than generic async style warnings.

Relationship to existing proposals

  • P-021 Own.Async: supplies the first task hazards and real-C# extraction seam. ASYNC001, ASYNC002, ASYNC020, and ASYNC030 are prerequisites, not competitors.
  • P-025 obligation protocols: provides the MVP mathematical shape: task creation opens an obligation; await/join/transfer closes it; scope exit or resource disposal is a barrier.
  • P-010 typestate: future stronger surface for ChildTask<Outstanding> -> ChildTask<Observed> and consume-on-transition task groups.
  • P-008 effects/resources: future spawn, block, ui-dispatch, channel-send/receive effects and layer policies.
  • Existing lifetime/region core: checks that captured resources and cancellation sources outlive child work.

Proposed static model

Do not attempt to infer runtime states such as Running or Completed. Track the analysis obligation instead:

Unowned
  -> Outstanding(scope)
  -> Observed
  -> Transferred(supervisor)
  -> ApprovedDetached

Invalid outcomes include:

Outstanding -> scope exit
Outstanding -> captured resource disposed
Detached without a configured supervisor
Transferred to a shorter-lived owner
Task consumed twice where the task-like value is single-use

At control-flow merges, outstanding task identities join by union. A task closed on only some paths becomes a maybe outstanding finding, following the existing definite/maybe policy.

Analyzer-only MVP

The first version should analyze existing C# APIs and project-configured helpers. No runtime package is required for this slice.

Recognize conservatively:

  • local Task / ValueTask creation;
  • await task;
  • Task.WhenAll(...);
  • Task.WhenAny(...) plus loser handling;
  • returning a task as ownership transfer to the caller;
  • storing a task in a configured long-lived supervisor;
  • configured Forget/observer helpers;
  • cancellation-token propagation where both parent contract and callee parameter are visible;
  • captured resources and lifetime regions;
  • task collections only where identity and join behavior remain provable.

Unknown or dynamic shapes must be reported as unsupported evidence, not guessed.

Candidate diagnostics

CONC001 - child task escapes its owning scope

A child task remains outstanding when the operation/request/component scope exits.

CONC002 - task failure is not observed

A task is created but never awaited, joined, returned, transferred to an approved supervisor, or passed to an approved observer.

CONC003 - faulting sequential await may orphan a sibling

Task a = AAsync(ct);
Task b = BAsync(ct);
await a;
await b;

If a faults, b remains unobserved. Recommend Task.WhenAll(a, b) or an explicit failure/cleanup policy.

CONC004 - cancellation capability not propagated

A cancellable parent calls a compatible cancellable child without passing the visible parent token, subject to configuration and overload evidence.

CONC005 - detached task has no approved supervisor

_ = ProcessQueueAsync();

Allow only configured supervisor/observer transfers with explicit lifetime and exception-observation semantics.

CONC006 - WhenAny leaves losing tasks outstanding

The winner is observed, but remaining tasks are neither cancelled, joined, nor transferred.

CONC007 - child captures a resource that dies first

A task captures a disposable, DI-scoped service, stream, buffer, UI component, or other known resource whose region ends before the child is observed.

CONC008 - invalid task-group protocol

Reserved for a later runtime-backed TaskGroup surface: spawn after close, child escapes the group, missing join, double close, or use after group disposal.

Numbering is provisional and should be reconciled with the final ASYNC versus CONC family decision before implementation.

OwnIR sketch

{
  "concurrency_scopes": [
    {
      "id": "OrderController.Handle",
      "parent": null,
      "cancellation": "requestToken"
    }
  ],
  "tasks": [
    {
      "id": "task:user",
      "scope": "OrderController.Handle",
      "created_at": 42,
      "captures": ["dbContext"],
      "cancellation_token": "requestToken"
    }
  ],
  "task_events": [
    {
      "kind": "join_all",
      "tasks": ["task:user"],
      "line": 46
    }
  ]
}

The exact schema is not committed. Prefer an additive fact family and stable task/scope identities compatible with finding identity v2 and evidence-ledger work.

Optional later runtime layer

A real structured-concurrency guarantee requires runtime participation. Consider a deliberately tiny package only after the analyzer-only model proves demand:

await using var group = TaskGroup.Create(ct);

ChildTask<User> user = group.Run(token => LoadUserAsync(token));
ChildTask<Orders> orders = group.Run(token => LoadOrdersAsync(token));

await group.JoinAsync();

Runtime responsibilities:

  • register dynamic children;
  • propagate cancellation;
  • cancel siblings according to policy;
  • join on close/dispose;
  • aggregate/observe failures;
  • expose explicit transfer to a longer-lived supervisor.

Analyzer responsibilities:

  • verify group ownership and lifetime;
  • reject child escape and use-after-close;
  • verify captured-resource lifetime;
  • verify cancellation provenance;
  • enforce architectural policies.

The runtime is an optional enforcement witness, not a replacement for the static model.

Channels and session-like protocols (later)

A bounded extension may model System.Threading.Channels:

Writer<Open> --Complete--> Writer<Closed>
write after complete = invalid transition
reader/writer task must remain owned by the containing scope

Useful initial checks:

  • write after complete;
  • double complete;
  • writer never completed where completion is required;
  • producer/consumer task not joined;
  • channel or endpoint escapes its owner scope;
  • missing cancellation on producer/consumer loops.

General deadlock freedom, scheduler fairness, and arbitrary session-type verification are explicitly out of scope.

Sequencing / entry criteria

Do not implement this issue until all of the following hold:

  1. At least the first Own.Async deterministic slices work end to end on real C#.
  2. Task creation/await/return facts have stable identities and evidence traces.
  3. Task.WhenAll and task-return transfer semantics have regression fixtures.
  4. At least one real bug corpus demonstrates value beyond existing analyzers.
  5. The project has a usable public CLI/package surface; this feature must not delay packaging again.

Suggested implementation order after entry:

  1. Generalize ignored/escaping-task facts into an outstanding-task obligation.
  2. Add await, return-transfer, and WhenAll discharge semantics.
  3. Add captured-resource lifetime checks.
  4. Add sibling-fault and WhenAny loser checks.
  5. Add cancellation capability propagation.
  6. Evaluate whether a tiny TaskGroup runtime has a concrete consumer.

Acceptance criteria for a first slice

  • A created local child task that reaches method exit unobserved produces a deterministic finding with creation-to-exit evidence.
  • await, Task.WhenAll, return-to-caller, and configured supervisor transfer discharge the obligation.
  • A child capturing a provably shorter-lived disposable/DI resource produces a lifetime finding.
  • Sequential sibling awaits have a regression fixture showing the fault path.
  • WhenAny distinguishes handled losers from outstanding losers.
  • Unsupported task identities fail loudly or remain silent with explicit coverage metadata; they never become false clean results.
  • No duplicate verdict logic is introduced in the Roslyn extractor.

Non-goals

  • No full CLR async state-machine or scheduler model.
  • No general data-race detector or happens-before proof.
  • No claim of deadlock freedom.
  • No scheduler fairness or performance analysis.
  • No automatic detection of children hidden by reflection, dynamic, opaque callbacks, or third-party internals.
  • No mandatory runtime dependency for the analyzer-only MVP.
  • No new general-purpose language syntax.
  • No implementation before the narrower Own.Async and packaging work are usable.

Product framing

Avoid the dishonest claim:

Own.NET brings structured concurrency to C#.

Prefer:

Own.NET retrofits structured-concurrency contracts onto existing C# async code by treating child tasks as owned lifetime obligations.

This is a promising extension of the ownership core, but it remains downstream of the concrete async and distribution milestones.

Activity

  1. PhysShell commented on Oct 1, 2026

    @PhysShell
    OwnerAuthor

    Follow-up after reviewing Stephen Cleary's StructuredConcurrency prototype: the static-analysis angle looks stronger than the original issue framing suggested.

    New prior-art anchor

    • https://github.com/StephenCleary/StructuredConcurrency
    • Cleary's TaskGroup makes child-task lifetime explicit at runtime: work belongs to a lexical group, the group does not complete until its children complete/cancel, sibling cancellation is coordinated on failure, and resources can be owned by the same group.
    • This is useful as a concrete runtime witness for the static contract proposed here, not as a dependency.

    The useful Own.NET question is narrower:

    Can ordinary C# task handles be treated as affine outstanding obligations, with await / join / approved transfer as consumption, and can Own.NET prove that no obligation is dropped on any control-flow path?

    Conceptually:

    spawn      -> TaskHandle<T>  // creates obligation
    await      -> consume
    WhenAll    -> consume set
    return     -> ownership transfer to caller
    supervise  -> ownership transfer to longer-lived owner
    detach     -> explicit policy-controlled transfer
    drop       -> finding
    

    This is closer to ownership/obligation analysis than to a generic "async best practices" analyzer.

    Kill-first PoC

    Before expanding the issue, test only whether the existing Roslyn -> OwnIR -> core architecture can prove these three properties on small C# fixtures:

    1. Path-sensitive discharge

      Task t = WorkAsync();
      
      if (condition)
          await t;

      Expected: finding on the exit path where t remains outstanding.

    2. Explicit ownership transfer

      Distinguish:

      • await t / Task.WhenAll(...) -> discharged;
      • return t -> transferred to caller;
      • configured supervisor API -> transferred;
      • field/property assignment -> not automatically safe unless the target is modeled as a valid longer-lived owner.
    3. Collections / dynamic children

      var tasks = new List<Task>();
      tasks.Add(AAsync());
      tasks.Add(BAsync());
      
      if (condition)
          await Task.WhenAll(tasks);

      Expected: prove the collection obligation closed only on paths where the join occurs; do not silently report clean if task identity is lost.

    Required hostile controls should include try/finally, loops, early returns, exceptions between sequential awaits, lambdas/local functions, using / await using, and task collections.

    Kill criterion

    Do not build a broader Own.Concurrent subsystem if the first PoC cannot express these cases without either:

    • embedding verdict logic in the Roslyn extractor;
    • losing task identity across ordinary CFG joins;
    • treating unknown escapes as safe;
    • or producing materially more noise than existing VSTHRD / async analyzers on equivalent fixtures.

    A PASS would justify the next benchmark question:

    Does affine task-obligation analysis find real structured-lifetime bugs in ordinary C# that are missed by Microsoft.VisualStudio.Threading.Analyzers / common async analyzers?

    The important differential is not "we also detect ignored tasks". It is ownership of outstanding work across control-flow and lifetime boundaries.

    This also gives a useful new domain for validating the general Own.NET ownership core: if the same affine/obligation machinery used for resources and typestate can model task lifetime without concurrency-specific special pleading, that is evidence the core abstraction is doing real work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions