I/O, Streams & HTTP · Lesson 10

Cancellation-aware asynchronous copying

Pass the token through the copy and do not mistake cancellation for rollback.

Cancellation is an operation contract rather than a rollback mechanism

An asynchronous copy can accept a CancellationToken so a caller can request that cooperative work stop. Passing a token does not make a sequence transactional or undo bytes already written before cancellation is observed. This lesson uses a token canceled before copying begins and a real memory-to-memory CopyToAsync operation, giving a deterministic cancellation boundary without timers or background threads. The source contains the UTF-8 bytes for rune, and the destination begins empty. The caller catches the expected cancellation separately from successful copying, then explicitly starts another attempt with an uncanceled token.

Follow the canceled attempt and the explicit retry

The cancellation source is canceled before its token reaches the first CopyToAsync. Awaiting that operation enters the OperationCanceledException catch, which prints cancelled. The sample then prints the destination length as 0, demonstrating the observed state of this pre-canceled memory copy. Before the next attempt, it explicitly assigns zero to the source position instead of depending on an implicit rewind. The second copy uses CancellationToken.None and completes, after which decoding the destination bytes prints rune. The three lines describe a specific pre-canceled case followed by an intentional new operation, not a universal rollback guarantee.

Await the operation that actually owns the work

CopyToAsync returns an awaitable operation, and the sample awaits it inside the try block. That is where an asynchronous failure is observed and where cancellation can be distinguished from success. Calling an asynchronous API does not itself require a new worker thread; memory-backed implementations can complete synchronously while still honoring the same awaitable contract. The embedded practice therefore uses genuine framework APIs without claiming background scheduling or parallel throughput. Keep the source and destination alive until the awaited operation finishes, and do not begin a second copy on the same mutable cursor merely because the first call has returned a task.

A buffer-size argument is not an observable chunk schedule

The sample supplies a buffer size of two, but it does not assert that a specialized memory-stream implementation performs exactly two-byte reads or offers a cancellation checkpoint after each pair. Implementations may optimize copying while preserving their documented contract. An algorithm that needs its own per-chunk accounting or progress semantics should implement and test that loop explicitly with the relevant asynchronous reads and writes. The output here deliberately avoids reporting internal operation counts. Its assertions concern only cancellation before work, the observed empty destination, and the final reconstructed text after the separate successful attempt.

Plan for partial state when cancellation occurs later

With an external source or a longer-running custom pipeline, cancellation may be observed after some bytes have already moved. A destination could then contain a valid prefix while the operation as a whole has not completed. Decide whether that prefix is discarded, retained for an explicitly supported resume protocol, or written to temporary storage until a commit boundary. Reusing the same canceled token cannot restart cooperative work; a new attempt needs an appropriate token and valid stream positions. This lesson avoids a timing-dependent mid-copy demonstration and does not claim that cancellation can retract an already accepted remote request or reverse another system's effects.

Change cancellation timing and source position independently

For the first exercise, remove the call that cancels the token before the initial copy. Predict which diagnostic line disappears, what destination length is observed, and why the later explicit rewind would append another copy unless the destination is also reset. For the second exercise, restore pre-cancellation but change the explicit source position before the successful attempt to one. Predict the final decoded text and explain why restarting an operation is separate from choosing its input range. Keep each modified experiment isolated so cursor changes do not obscure the cancellation policy being tested.

  • Hint without pre-cancellation: the first copy succeeds and leaves four bytes; a later successful append produces runerune.
  • Hint for source position one: the successful retry reads only the remaining bytes and prints une.

Document success, cancellation and ownership separately

A reliable copy helper should state who owns its streams, whether it leaves them open, which token reaches its actual operations and what destination state may remain on failure. The original program owns both memory streams through using declarations and awaits each attempt before using the same objects again. It catches only the expected cancellation for the first operation, preserving other failures rather than labeling them as user cancellation. Review these boundaries before adding progress reporting or an external destination. A small deterministic pre-cancellation test is useful evidence, but it should remain one test case within the larger partial-progress contract.

  • Pass cancellation through the operation that performs the copy and await its completion.
  • Do not interpret cancellation as automatic rollback or a reusable canceled token as a fresh attempt.
  • Keep chunk scheduling assumptions out of tests unless the implementation explicitly owns that schedule.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.IO;
using System.IO.Compression;
using System.Buffers.Binary;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;

using var input = new MemoryStream(Encoding.UTF8.GetBytes("rune")); using var output = new MemoryStream(); using var lifetime = new CancellationTokenSource(); lifetime.Cancel();
try { await input.CopyToAsync(output, 2, lifetime.Token); } catch (OperationCanceledException) { Console.WriteLine("cancelled"); }
Console.WriteLine(output.Length); input.Position = 0;
await input.CopyToAsync(output, 2, CancellationToken.None); Console.WriteLine(Encoding.UTF8.GetString(output.ToArray()));

Expected output

cancelled
0
rune
Try this
Cancellation can leave partial effects in general; this pre-canceled MemoryStream case is intentionally narrower.

Further reading