Repository navigation
C# async export traps when the caller cancels it #1759
Description
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. WaitableInfoStatekeeps 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
Callbackfrees the context but does not drop the waitable set and does not remove thependingTasksentry. A C# export task that waits at least once probably keeps one host waitable set and one dictionary entry. ReaderBase.Disposedrops the handle but does not set it to zero. A secondDisposewould drop the handle twice.
What a fix needs
- Import
[task-cancel](from[export]$root) and[subtask-cancel]. - A cancel path in
CallbackforEventCode.Cancel: cancel each operation in flight (cancel-read/cancel-writefor futures and streams,subtask.cancelfor import calls), complete the .NET tasks as cancelled, drop the reader or writer handles, drop the waitable set, free the context, and calltask.cancelunless the export already returned. - A way for the runtime to know that
task.returnalready ran, so thattask.cancelis never called after it or twice. Disposethat 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