Skip to content

core: process the active case of a C# 15 union command result - #199

Open
carldebilly wants to merge 2 commits into
mainfrom
dev/cdb/union-results
Open

carldebilly wants to merge 2 commits into
mainfrom
dev/cdb/union-results

Conversation

@carldebilly

@carldebilly carldebilly commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Closes #177.

What changed

  • CommandInvoker.InvokeAsync replaces a union result by its active case (System.Runtime.CompilerServices.IUnion.Value) after the await, for sync, Task<T> and ValueTask<T> handlers. Everything downstream (middleware's ReplExecutionContext.Result, classification, exit codes, every output format, Repl.Testing) sees the case.
  • A case that is itself a Task<T>/ValueTask<T> is awaited, as the same task returned directly would be; a faulted task fails the run the same way. Only values that come out of a union are awaited, so a handler's own Task<Task<T>> keeps its single await.
  • A union in a union is unwrapped (bounded at 16 steps, a guard against a custom IUnion that leads back to itself); a default union or a case holding null behaves like null.
  • A union element of a returned tuple is processed the same way, and a union a middleware sets as ReplExecutionContext.Result is processed once the pipeline completes.
  • Compiled for net11.0 only (#if NET11_0_OR_GREATER): native unions need the .NET 11 runtime. net10.0 is unchanged.
  • docs/commands.md: a "Union return values (.NET 11)" section with a working example, the semantics and the limits, plus a row in the return-pattern table.

Verified on SDK 11.0.100

IUnion is in corelib with object? Value; value-type cases come out boxed; default(union).Value is null; a union holding a union yields an IUnion.

Tests

Given_UnionResults (net11.0 only), written first. The awaitable-case fix added 9 more tests that failed before it (Task and ValueTask cases, an IExitResult behind a task, a faulted task compared with the task returned directly, a middleware setting a union, a tuple element whose case is a task). Also covered: sync/Task/ValueTask handlers, value-type, default and null cases, nested unions, IExitResult and IReplResult cases compared with the same result returned directly, JSON and human output equal to the bare payload, the depth bound, a middleware observing the active case, and a union parameter (usage error from command input, injected from the services).

Full suite on net10.0 and net11.0: 5594 total, 0 failed, 74 skipped (Windows); -warnaserror and markdownlint clean.

Limits, documented

  • A union gets no conversion from command input: like any type Repl cannot convert, a value given for a union parameter is a usage error (exit code 2). A union parameter can still come from the services. A general Map-time check for unconvertible input types is Refuse at Map a command-input parameter whose type cannot be converted #202; union input support only on a real use case.
  • A union passed as the payload of a Results helper (Results.Exit(3, lookup)) is rendered as an ordinary object.
  • A union built on a polyfilled IUnion (an assembly compiled for net10.0) gets no unwrapping, even on the .NET 11 runtime.
  • McpAppResourceInvoker calls its handlers without going through CommandInvoker, so a UI resource handler returning a union is not unwrapped; those handlers return strings.
  • Discovery does not look inside a union: the command is not described as paged even when every case is a page. The MCP anyOf output schema is mcp: describe union command results with anyOf in the output schema #185.

@carldebilly carldebilly added this to the 0.12.1 milestone Nov 11, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-11T06:04:57.685492Z f273279 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@autocarl autocarl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested at exact head 9e80985, against merge-base 00b35aa. One introduced P2 confirmed by public-API execution: awaitable union cases bypass awaiting, leak task wrappers into output and can lose an explicit exit code. See inline reproduction and controls. Independent read-only review covered all five changed files; parent inspected the delta/callers and executed the candidate. No other concrete introduced P0-P2 established. Documented deferred MCP schemas/UI resources, paging discovery and net10 polyfills are not reported as defects.

Validation: exact-head Release unit-test project build with warnings as errors passed; both unit-test targets passed, 1348 net10.0 plus 1360 net11.0 (2708 passed, 0 failed/skipped). These existing tests do not cover the reproduced awaitable-case failure. External SDK11 probe built and executed against this checkout; no product source edits or package publication.

Separate unresolved CI gate: Build, Test, Pack failed in Repl.RemoteTests/net11.0, When_TheHostStops_Then_CommandCleanupCannotDisableItsDeadline(blocking-callback,1): exit 0 instead of expected 1. This is not attributed to the union delta without introduction evidence; it remains unvalidated rather than being dismissed as flaky. Local unit-test success is not a claim of green full CI.

Comment thread src/Repl.Core/Parsing/CommandInvoker.cs Outdated
Comment on lines +30 to +33
public static async ValueTask<object?> InvokeAsync(
Delegate handler,
object?[] arguments) =>
UnwrapUnion(await InvokeHandlerAsync(handler, arguments).ConfigureAwait(false));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Await active awaitable cases before classifying the union result

InvokeHandlerAsync awaits only the original delegate result; UnwrapUnion then exposes a Task/ValueTask active case after that awaiting step, so it reaches rendering as the task wrapper rather than its awaited value. This violates the documented active-case-as-direct-return contract and can silently change an explicit failure/exit verdict into success.

Reproduced through the public ReplApp API on net11.0 at this exact head: define public record ExitPayload(int ExitCode, object? Payload) : IExitResult; and public union ExitCase(Task, ExitPayload);. A handler returning Task.FromResult(new ExitPayload(7, "done")) directly prints "done" and exits 7. Returning (ExitCase)Task.FromResult(new ExitPayload(7, "done")) instead prints the Task properties (result, status, id, etc.) and exits 0. Plain Task and ValueTask union cases likewise render wrappers, while the direct-return controls and a non-awaitable union case render the bare payload. A faulted Task case also changes the classified error compared with the direct Task.

Normalize the active case through awaitable handling before downstream classification/rendering (with the intended cycle/depth bound), and cover both Task and ValueTask active cases, including exit-result and faulted cases.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f273279. InvokeAsync now awaits a case that is a Task<T>/ValueTask<T> inside the bounded unwrap (UnwrapUnionAsync, sharing AwaitResultAsync with the handler's own result), so the case behaves as the same task returned directly. Only values that come out of a union are awaited; a handler's own Task<Task<T>> keeps its single await.

Your reproduction is now a test: (UnionPending)Task.FromResult(new UnionExit(7, "done")) exits 7 and prints done. Also covered, all red before the fix: Task and ValueTask cases, a faulted Task case whose exit code and output equal those of the same faulted task returned directly, and a tuple element whose union case is a task. The same commit processes a union a middleware sets as ReplExecutionContext.Result.

The branch is rebased on main (now including #198); full suite on net10.0 and net11.0: 5594 total, 0 failed, 74 skipped.

Base automatically changed from dev/cdb/net11-multitarget to main November 11, 2026 02:38
@carldebilly
carldebilly force-pushed the dev/cdb/union-results branch from 46ed50a to 806bc8c Compare November 11, 2026 02:58
On net11.0 a handler may return a union (sync, Task<T> or ValueTask<T>). The
command invoker replaces it with the case it holds, after the await, so exit
codes, error classification, rendering and middleware see the value the handler
meant to return. A union nested in a union, or placed as an element of a returned
tuple, is unwrapped too; a default union behaves like null.

The unwrapping compiles for net11.0 only: native unions need the .NET 11 runtime,
and net10.0 behaviour is unchanged. A union is not a bindable input and is
refused as a usage error, which a test now pins.
A union whose active case was a Task<T> or ValueTask<T> reached rendering as
the task: an IExitResult behind it was lost (exit 0) and a faulted task passed
as success. The case is now awaited as the same task returned directly would
be, inside the bounded unwrap; only values that come out of a union are
awaited, so a handler's own Task<Task<T>> keeps its single await.

A union a middleware sets as ReplExecutionContext.Result is unwrapped before
rendering, and a union element of a returned tuple gets the same await.

A union handler parameter keeps the generic behaviour of any type Repl cannot
convert from command input (a usage error), and can still come from the
services; the union-specific message is gone, and #202 tracks a Map-time check
for every unconvertible input type. The docs list the union semantics and
limits.
@carldebilly
carldebilly force-pushed the dev/cdb/union-results branch from 806bc8c to f273279 Compare November 11, 2026 06:03

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support C# 15 union command results and assess related .NET 11 features

2 participants