Task / ValueTask bridges
Work that returns a Task, like reading a file or a network call, is awaited from a flow through a bridge. This page explains how to use FlowBridge.FromTask and .AsFlow(), how exceptions and external cancellation are handled, how to clean up results nobody received, and AsTask() for awaiting a result from outside a flow.
Awaiting external work: FromTask
Section titled “Awaiting external work: FromTask”async FlowTask<Stage> LoadStage(string path){ // The token is canceled when this scope is canceled. var json = await FlowBridge.FromTask(ct => File.ReadAllTextAsync(path, ct)); return Stage.Parse(json);}You pass FlowBridge.FromTask a factory that takes a CancellationToken and returns a Task.
- Like any other FlowTask, the factory is called when the bridge starts (when you await it, or when a composition you passed it to starts).
- The token it receives is canceled when the scope is canceled. The cancellation reaches the external work both when the bridge loses a Race and when its parent ends.
- Whatever thread the Task completes on, the flow resumes on the World’s thread, at the next Tick or Flush. To run heavy computation on another thread, write
FlowBridge.FromTask(ct => Task.Run(() => Work(), ct))and wait on it the same way (Threads).
A Task with no result works the same way (FlowBridge.FromTask(ct => SaveAsync(data, ct))).
Awaiting a running Task: AsFlow
Section titled “Awaiting a running Task: AsFlow”Wrap a Task or ValueTask that has already started in .AsFlow() and await it.
var download = http.GetStringAsync(url); // started elsewhere, without the scope's tokenvar body = await download.AsFlow();.AsFlow() doesn’t pass cancellation through. When the scope is canceled, the flow stops waiting and unwinds, but the Task keeps running. If the work can take a token, use FromTask.
Don’t await directly
Section titled “Don’t await directly”Inside a FlowTask method, don’t await an external awaitable directly.
async FlowTask LoadStage(){ var json = await File.ReadAllTextAsync(path); // FLOW002: not bridged await Task.Delay(500); // FLOW002: ignores the World's time and Pause}While a flow waits on an external awaitable, FlowTask can neither cancel nor unwind its scope. So if the flow suspends there, the scope ends at that point with a FlowMisuseException.
- This covers
Task,ValueTask, UniTask, Unity’sAwaitableandAsyncOperation, Godot’sSignalAwaiter,Task.Yield(), the result ofConfigureAwait(...), andawait foreach. - The rest of the scope’s
finallyblocks andusingstatements don’t run. Only cleanup fromFlow.AddCleanupandFlow.Ownruns. - The exception is thrown at the awaiter’s await, and you can catch it with
catch. The message, such asFlowTask method '…' suspended on TaskAwaiter<Int32>, which is not a FlowTask, shows the awaiter type, the scope path, and the bridge to use. - An await that completes immediately doesn’t suspend, so it isn’t caught at run time. It can pass in tests with an already-completed fake Task and fail only in production.
FLOW002 (an error) stops this at compile time. Its code fix rewrites the await to FromTask or .AsFlow(). Use FlowTask.WaitForSeconds instead of Task.Delay, and FlowTask.NextFrame() instead of Task.Yield() (Time and Clocks).
Bridges for Unity and Godot awaitables are in Unity bridges and Godot signals.
Exceptions and external cancellation
Section titled “Exceptions and external cancellation”An exception from a bridged Task is thrown at its await. You can catch it with an ordinary try / catch.
try{ var save = await FlowBridge.FromTask(ct => cloud.Download(ct)); Apply(save);}catch (HttpRequestException e){ await ShowError(e.Message);}catch (OperationCanceledException e) when (e is not FlowCanceledException){ // HttpClient's timeout: the Task was canceled outside the flow. await ShowError("Timed out");}A Task canceled outside the flow (an HttpClient timeout, a CancellationToken passed in from outside) arrives as an ordinary exception, not as a cancellation of the flow. If you don’t catch it, it becomes an unhandled exception.
- When the scope itself is canceled, the bridge unwinds without reading the result. An
OperationCanceledExceptionreaches the bridge’s await only when the cancellation came from outside the scope. - To catch only external cancellation, write
catch (OperationCanceledException e) when (e is not FlowCanceledException). Acatch (OperationCanceledException)without a filter also swallows the flow’s own cancellation (FLOW001). catch (TaskCanceledException)is sometimes not enough. Cancellation from UniTask andThrowIfCancellationRequestedis a plainOperationCanceledException.- When an exception from a Task is reported to
OnUnhandledException, itsScopePathis the path of the place where it was bridged.
How to turn an external exception into an expected failure that the caller handles is covered in Handling failures.
Cleaning up results nobody received: onDiscard
Section titled “Cleaning up results nobody received: onDiscard”For work that returns something that needs releasing, like an Addressables handle, pass onDiscard.
var boss = await FlowBridge.FromTask( ct => assets.Load("boss", ct), onDiscard: a => a.Release()); // called only for a result no flow receivedonDiscard receives, once, a successful result that no flow received.
- It is called for: a result that arrives after the bridge was canceled, a result nobody took because the awaiter unwound, a result that lost a Race in the same flush, a result inside a WhenAll that failed, and a result ignored with
WithoutResult(). - A value a flow received with await belongs to that flow. Even if the flow throws it away later,
onDiscardisn’t called. - A bridge’s value passed to Race or WhenAll counts as received once the composition receives it. Even if it is thrown away before the composition’s result reaches a flow (for example, because it lost an outer Race),
onDiscardisn’t called. - A bridge started with
Flow.SpawnorFlowWorld.Runkeeps its value for the handle (itsResultcan be read at any time), soonDiscardisn’t called for it. - It is called on the World’s thread, while taking in completions at the next Tick or Flush, or inside
FlowWorld.Dispose. - However, if the Task finishes after the World has been disposed, it is called right away on the thread that completed the Task (usually a thread pool thread). If you call engine APIs there, move back to the main thread first. In this case, an exception thrown by
onDiscardis dropped without being reported. - Otherwise, an exception thrown by
onDiscardis reported asFlowExceptionKind.Cleanup.
For a bridge without onDiscard, a result nobody received is dropped without a report, the same as the result of a Task nobody awaits.
If the Task fails after the bridge has stopped waiting, the exception is reported as FlowExceptionKind.Undelivered (the exception is marked as observed and doesn’t become an UnobservedTaskException). If it ends with cancellation, nothing is reported.
Passing bridges to compositions: cancel buttons and timeouts
Section titled “Passing bridges to compositions: cancel buttons and timeouts”You can also pass bridges to Race and WhenAll. Turn a bridge with a result into a FlowTask<T> with .ToFlowTask() before passing it. Without it, the type argument can’t be inferred and you get a compile error (CS1503). A bridge with no result can be passed as is.
var r = await FlowTask.Race( cancelButton.Next(), FlowBridge.FromTask(ct => assets.Load("boss", ct), onDiscard: a => a.Release()).ToFlowTask());if (!r.TryGet1(out var boss)) return; // canceled: ct was canceled, and a result that still arrives goes to onDiscard- The token of a bridge that loses a Race is canceled. If the work takes the token, it stops there.
- Work that can’t be stopped (such as a load that doesn’t take a token) runs to the end. No flow receives the result that arrives, so it goes to
onDiscard(see “Cleaning up results nobody received” above).
You can also set a time limit with Race.
var r = await FlowTask.Race( FlowBridge.FromTask(ct => http.GetStage(ct)).ToFlowTask(), FlowTask.WaitForSeconds(10));if (!r.TryGet0(out var json)) return ApiError.Timeout; // the request was canceled through its tokenWaitForSeconds advances on the scope’s Clock, so the limit also stops while the game is paused. To limit a network call in real time, use the external side’s own mechanism (HttpClient.Timeout, CancellationTokenSource.CancelAfter). In that case, the cancellation arrives as an exception (see “Exceptions and external cancellation” above). Using compositions is covered in Composition.
Stopping a flow with an external token
Section titled “Stopping a flow with an external token”To stop a flow with a CancellationToken passed in from outside, register the handle’s Cancel. You can call Cancel from any thread.
async Task<Save> Download(CancellationToken token){ var handle = world.Run(DownloadSave()); using var registration = token.Register(handle.Cancel); return await handle.AsTask(); // canceled when the flow is canceled}A Cancel from another thread takes effect at the next Tick or Flush. Until then, Status doesn’t change. If the World has been disposed, or the flow has finished, it does nothing.
Awaiting a result from outside a flow: AsTask
Section titled “Awaiting a result from outside a flow: AsTask”Don’t await a FlowTask inside a method that returns Task (FLOW005, an error). Start the flow with FlowWorld.Run and await the handle’s AsTask().
Task<int> GetScore() => world.Run(Arcade()).AsTask();- The Task completes on the World’s thread, right after the Tick, Flush, Run, or Dispose in which the flow ended. If the flow is Succeeded, the Task is RanToCompletion; if Canceled, Canceled; if Faulted, Faulted with the original exception.
- The Task’s continuation runs synchronously on the World’s thread. That’s also true for code that does
await handle.AsTask()on a background thread, even withConfigureAwait(false). Move heavy work elsewhere withTask.Runor similar. - Inside the continuation, don’t wait for another
AsTask()with.Resultor.Wait(), and don’t spin Tick to wait for it. A Tick inside the continuation doesn’t complete AsTask, so it never finishes. Check the handle’sStatusorIsCompletedinstead. - If a flow or one of its descendants bridges and awaits the flow’s own
AsTask(), it never finishes, because a flow ends only after its children end. This isn’t detected. - Calling
AsTask()doesn’t use up the handle’sJoin(). You can call it any number of times. - Call
AsTask()on the World’s thread (from another thread it throwsFlowThreadException).
The Task completes at the end of the World’s Tick or Flush, so it doesn’t complete unless code keeps the World going. On Unity and Godot, the integration ticks every frame. In a console program, you tick it from your own loop (Your first flow).
On the World’s thread, don’t block on this Task with .Result or .Wait(). Tick stops too, so it never finishes. You can read the value of a finished flow with handle.Result. From another thread, you can block on a Task you got on the World’s thread (Threads).
Flows await each other with Join
Section titled “Flows await each other with Join”Flows await each other with the handle’s Join(), without going through a Task.
var loading = Flow.Spawn(LoadAssets());await PlayIntro();await loading.Join();However, you can’t use Join when the other World is on a different thread (the await fails with FlowThreadException). Get AsTask() on the other World’s thread, and await it through a bridge.
// On the other World's thread:Task<Map> mapTask = otherWorld.Run(BuildMap()).AsTask();
// In a flow of this World:var map = await FlowBridge.FromTask(_ => mapTask);Don’t connect flows on the same thread this way. If the other flow fails and the awaiting side doesn’t catch the exception either, the same exception is reported twice (once for the other flow, and once for the awaiting side).
Details
Section titled “Details”- Each bridge allocates. For per-frame waits, use FlowTask’s own waits (
WaitForSeconds,NextFrame, signals) (Performance). - On Unity, if you await inside the factory without
ConfigureAwait(false), the continuation goes throughUnitySynchronizationContext(Unity bridges). - Notes on making fake Tasks in tests are in Testing.
- For UniTask, see the UniTask page; for R3, see the R3 page.