Skip to content

C# async export traps when the caller cancels it #1759

Open

Description

@Aditya1404Sal

Summary

A C# async export cannot handle a call that the caller cancels while the export waits. The runtime support traps on the cancel event, and the generated code never calls task.cancel.

Where

crates/csharp/src/AsyncSupport.cs, AsyncSupport.Callback. The method does not look at the event code. It starts with pendingTasks[set][e.Waitable]. A cancel request arrives as EventCode.Cancel with waitable 0, so the lookup throws KeyNotFoundException: The given key '0' and the component traps.

Also:

  • No C# code imports [task-cancel] or [subtask-cancel]. The generator leaves // TODO: task_cancel.forget(); in every async export.
  • WaitableInfoState keeps no subtask handle for an async import call, so such a call cannot be cancelled either.

Reproduction

package my:test;

interface i {
  pending: async func(x: future);
}

world test {
  export i;
}

world runner {
  import i;
  export run: async func();
}

The C# export:

public static async Task Pending(FutureReader x)
{
    await x.Read();
}

A Rust runner creates a future, calls pending(rx), polls the call once (it is pending), drops it, and writes to the future. A cancelled callee has dropped its reader, so the write must fail.

Result: the C# component traps before the drop completes.

Unhandled exception. System.Collections.Generic.KeyNotFoundException: The given key '0' was not present in the dictionary.
   at System.Collections.Concurrent.ConcurrentDictionary`2.ThrowKeyNotFoundException(TKey)
   at System.Collections.Concurrent.ConcurrentDictionary`2.get_Item(TKey)
   at TestWorld.AsyncSupport.Callback(EventWaitable)
   at TestWorld.wit.Exports.my.test.IExportsInterop.PendingCallback(Int32, UInt32, UInt32)

Control: the same runner with a Rust export (async fn pending(x: FutureReader<()>) { x.await }) passes, and the write fails as expected.

Run through wit-bindgen's test harness (cargo run test --languages rust,csharp …) with .NET SDK 10.0.403, WASI SDK 34 and wasmtime 51.0.0-dev, on the branch of #1758, which does not change AsyncSupport.cs.

No existing runtime test cancels a waiting C# export, so this path never ran before. The disabled C# future-cancel-read test (#1652) is a different case: there the export cancels its own read, nobody cancels the export.

Related, found by reading (not measured)

  • The normal exit path of Callback frees the context but does not drop the waitable set and does not remove the pendingTasks entry. A C# export task that waits at least once probably keeps one host waitable set and one dictionary entry.
  • ReaderBase.Dispose drops the handle but does not set it to zero. A second Dispose would drop the handle twice.

What a fix needs

  • Import [task-cancel] (from [export]$root) and [subtask-cancel].
  • A cancel path in Callback for EventCode.Cancel: cancel each operation in flight (cancel-read/cancel-write for futures and streams, subtask.cancel for import calls), complete the .NET tasks as cancelled, drop the reader or writer handles, drop the waitable set, free the context, and call task.cancel unless the export already returned.
  • A way for the runtime to know that task.return already ran, so that task.cancel is never called after it or twice.
  • Dispose that sets the handle to zero.

User code runs inside the callback when its task completes as cancelled, so the cancel path must take a snapshot of the pending waitables first and must check whether the user code joined a new waitable before it drops the set.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions