CLR & Concurrency · Lesson 23

Channels own completion and terminal failure

Drain accepted items and observe the actual terminal task separately from admission.

External .NET 10 SDK / desktop runtime. OS-thread experiments run only here; the .NET 9 browser companion has the narrower scope stated in the guide.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

The runtime contract

A channel has related but distinct admission, buffered-data, and terminal-completion states. Completing its writer ends further admission, but already accepted values can remain available to the reader. A supplied completion failure must also be observed through the terminal task rather than silently discarded after draining. This portable program uses the actual System.Threading.Channels implementation, with capacity two and no OS threads. It is not a queue model claiming to implement channel semantics. The desktop project executes the same bounded protocol, separate from any claim about distributed messaging, durable queues, or production worker shutdown.

Trace the portable program

Two and four are admitted before the writer is completed with a controlled InvalidOperationException. TryRead then drains those accepted values in order, so the first line is items:2,4. Awaiting Reader.Completion observes the real terminal failure and prints failure:InvalidOperationException. A later TryWrite is rejected, producing write-after:False. Draining the values did not convert faulted completion into success. A common wrong answer assumes completion throws away buffered work, or treats cancellation of one reader's wait as permission to close the entire channel owned by all producers and consumers.

Execute the actual desktop lab

The desktop project executes the same actual channel protocol as the portable program. Capacity is two, both writes occur before completion, and no concurrent producer is invented. Reader.Completion represents the terminal reader state and is observed after buffered data is removed. The chosen error is an ordinary InvalidOperationException, so the terminal task faults rather than merely reporting successful exhaustion. Different read APIs can expose closed or faulted state through their own documented shapes, which is why the baseline deliberately observes the Completion task explicitly. The result is bounded lifecycle evidence, not a claim of worker throughput or durable message delivery.

Pitfalls and boundary limits

An empty queue can mean temporarily no data, permanently completed, or faulted after earlier data was drained. A consumer must use the appropriate channel protocol to distinguish those states. Writer ownership also matters when several producers exist: one producer finishing its own work does not necessarily authorize it to complete a channel still used by others. Canceling a single WaitToReadAsync or ReadAsync wait is another separate event. Backpressure and drop policies are configuration contracts, not implied by the word bounded. The baseline uses the ordinary waiting capacity policy and explicit completion to keep those assumptions visible.

Practice one: isolate behavior

First replace the completion error with successful completion and predict the same item line but no failure line, with later writes still rejected. Hint: remove or adjust the error observation deliberately; draining data alone does not determine terminal success. Then complete a fresh empty channel and observe its terminal task without expecting a fabricated item. Keep the output tied to the exact completion owner and error value supplied. These scenarios distinguish admission ending, buffered data, and terminal outcome instead of treating every empty read as the same kind of completion.

Practice two: design and verify

For another exercise, add two bounded producers with one explicit coordinator responsible for completing the writer only after both finish. Define how producer failure reaches the reader and ensure every owned operation is awaited. Hint: a producer should not close shared admission simply because its personal input ended. Add cancellation of one waiting reader and verify the channel can still accept and deliver data to an authorized consumer afterward. Keep those local lifetime tests separate from any production retry, acknowledgment, or durability guarantee that requires a real messaging or storage system.

Review the evidence

Review a channel pipeline by naming admission policy, producer count, completion owner, reader drain responsibility, cancellation scope, and terminal-error observation. The three baseline lines establish accepted data followed by a real failure and rejected later admission. The external run confirms the same BCL lifecycle without pretending to deploy a broker. A complete worker system needs further decisions about unfinished work, shutdown deadlines, and effects after removal. Preserve those boundaries explicitly so channel completion is a well-owned protocol transition rather than a convenient method call whose consequences every participant interprets differently. Observe terminal tasks even when individual item processing appeared successful.

External .NET 10 SDK / desktop runtime. OS-thread experiments run only here; the .NET 9 browser companion has the narrower scope stated in the guide.

Channels own completion and terminal failure — desktop runtime lab

Set up the SDK project

  1. Save both files at their exact paths in a fresh owned directory.
  2. Use a .NET 10 SDK, then run dotnet run --project RuntimeLab.csproj. This project intentionally uses C# 13 syntax.
  3. Workers and waits are bounded; a timeout or a violated invariant fails the process. No benchmark or NativeAOT publication is implied by an ordinary run.

Download complete SDK project ↗

Project files

Program.csDownload file ↗
using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

var channel = System.Threading.Channels.Channel.CreateBounded<int>(2);
if (!channel.Writer.TryWrite(2) || !channel.Writer.TryWrite(4)) throw new InvalidOperationException();
channel.Writer.TryComplete(new InvalidOperationException("controlled"));
var items = new List<int>();
while (channel.Reader.TryRead(out int item)) items.Add(item);
Console.WriteLine("items:" + string.Join(",", items));
try { await channel.Reader.Completion; } catch (InvalidOperationException error) { Console.WriteLine($"failure:{error.GetType().Name}"); }
Console.WriteLine($"write-after:{channel.Writer.TryWrite(6)}");
RuntimeLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>13.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PublishAot>false</PublishAot>
  </PropertyGroup>
</Project>

Expected result

Exact behavioral stdout on the stated desktop lab: items:2,4 failure:InvalidOperationException write-after:False Variable diagnostics, where emitted, go to stderr and must not be treated as portable timing or allocation guarantees.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

var channel = System.Threading.Channels.Channel.CreateBounded<int>(2);
if (!channel.Writer.TryWrite(2) || !channel.Writer.TryWrite(4)) throw new InvalidOperationException();
channel.Writer.TryComplete(new InvalidOperationException("controlled"));
var items = new List<int>();
while (channel.Reader.TryRead(out int item)) items.Add(item);
Console.WriteLine("items:" + string.Join(",", items));
try { await channel.Reader.Completion; } catch (InvalidOperationException error) { Console.WriteLine($"failure:{error.GetType().Name}"); }
Console.WriteLine($"write-after:{channel.Writer.TryWrite(6)}");
Open C# practice

C# practice output

items:2,4
failure:InvalidOperationException
write-after:False
Try this
Writer completion ends admission; buffered values and terminal failure still need observation. Canceling one wait is not completing the channel.

Further reading

Interview questions for this lesson