Skip to content

The async graph on .NET

Async bodies return tasks; the graph tracks their synchronous reads and publishes completions on the graph thread. Flight policies decide what happens when inputs change during a request. Suspension explains how pending state reaches readers.

Async as a property of every computation

Solid 2's release-candidate async design allows async values to participate in ordinary computations and propagate pending state to loading boundaries. Ranvier adopts that pending-channel idea, without claiming identical scheduling or rendering:

  • Any memo, effect or boundary becomes Pending when it reads a pending value. It needs no special constructor to do so.
  • Values that genuinely arrive later come from three kinds of node, all implemented:
    • createAsync computes the value from a task.
    • createAsyncSource is settled by hand.
    • createStream and createStreamFold publish the latest item of a stream, or a fold of its items, and restart the stream when start's reads change; see Streams.

Tasks enter at the edge of a body

Implemented.

A body that re-runs from the top cannot await in the middle of a run. If it did, a re-run would restart the body while an earlier run was still waiting. Instead, the body returns a task, and the engine awaits it:

let profile =
    createAsync (fun _ (ct: CancellationToken) ->
        let id = userId.Value            // tracked: read before the first await
        fetchUserAsync (id, ct))         // Task<User>; the engine awaits it and marks the memo Pending
  • createAsync (compute: Previous<'T> -> CancellationToken -> Task<'T>) returns an AsyncMemo<'T>. It is lazy: the first read starts a flight, and the memo is Pending until the task completes. When a source that the body read changes, the next read starts a new flight.
  • Dependencies are tracked only in the synchronous part of the body, before the first await that suspends. A signal read after that point is not a dependency, so changing it does not start a new flight. Read every input before the first await.
  • The primitive is Task<'T>, not ValueTask<'T>, because the engine keeps a flight and may consult it again. A ValueTask cannot be awaited twice.
  • createAsyncSource<'T> () returns an AsyncSource<'T> that starts Pending. Settle value publishes a value, and Fail exn publishes a failure. Settle applies no equality cutoff: settling the same value twice still wakes every reader.

If the body reads a pending source before it returns a task, the async memo waits on that source. No flight starts until the source settles.

Superseded flights

Implemented.

A new flight supersedes one that is still in progress. The async memo's flight policy controls what happens to the older flight. GraphOptions.FlightPolicy sets it for the graph; createAsyncWithPolicy and createOwningAsyncWithPolicy set it for one async memo, and memos with different policies in one graph fly independently:

PolicyOld token cancelledSuperseded result discardedEvery result applied
CancelPrevious (default)YesYesNo
KeepLatestNoYesNo
QueueNoNoYes, in the order the flights started

FinishCurrent starts no flight while one is in progress. The flight finishes, and every change during it folds into one trailing run against the current inputs; the memo stays pending until that run settles.

Drop and DropWhilePending also start no flight while one is in progress: the change is discarded, and the settled flight's value stays current. Under DropWhilePending, a flight that fails after discarding a change runs once more against the current inputs instead of publishing its error.

Pass the CancellationToken to the I/O that the flight performs. Under CancelPrevious, cancellation is requested for superseded work; the operation must cooperate. This does not guarantee that its I/O or external side effects stop. JavaScript also has cancellation mechanisms such as AbortController; Ranvier's .NET API supplies the token used by .NET tasks.

Every flight's exception is observed, including a superseded flight's and one raised after its memo or graph was disposed. TaskScheduler.UnobservedTaskException receives none of them. Async and pending maps the policies to the names used by R3, SignalsDotnet and CommunityToolkit.Mvvm.

Not implemented

Exploratory. The following ideas from the design research have not been built:

  • an Async<'T> adapter for cold, restartable F# async workflows
  • a coalescing policy that publishes the finished flight's result before the trailing run (SignalsDotnet ScheduleNext, R3 ThrottleFirstLast); FinishCurrent keeps the memo pending until the trailing run settles

Timed values capture eagerly and delay publication. Read one from an async memo to control request admission, independently of the flight policy.

Threads and dispatch

Implemented.

A JavaScript graph normally runs within one execution context; workers are separate contexts. On .NET, a task usually completes on the thread pool. Ranvier gives each graph an owning thread and marshals completions back to it.

  • Ownership. A graph belongs to the thread that constructed it. Under ThreadAffinity = Guarded (the default), a signal write, node creation, batch, flush, disposal or stale read from another thread raises InvalidOperationException, and the error message names both threads. Unchecked removes the check when every access is known to obey the host's synchronization rules. Fable needs no affinity override: all three policies run in its single JavaScript context without .NET thread checks. Serialised accepts any thread on the construction context, one at a time; see Hopping synchronisation contexts.
  • Inbox. Completions from other threads, such as AsyncSource.Settle and finished AsyncMemo flights, go into the graph's inbox. Graph.Pump () applies them in arrival order on the owning thread and then flushes effects.
  • Dispatcher. GraphOptions.Dispatcher controls how the owning thread learns that work is waiting:
DispatcherBehaviour
None, with a SynchronizationContext present at constructionPosts the drain to that context. UI threads in WPF, WinForms and Avalonia work this way with no further setup.
None, with no SynchronizationContextUses ManualDispatcher.
Some (ManualDispatcher ())The inbox fills and is drained when the owning thread calls graph.Pump ().
Some (ImmediateDispatcher ())Drains on the thread that posted. Under Guarded, an off-thread drain raises and the work stays queued.

Without a SynchronizationContext, an off-thread settle remains queued until graph.Pump (). Tests can use that delay to assert state before and after delivery. Once inside the graph, writes propagate synchronously.

Contracts lists which operations each thread may call and where each kind of work runs.

Awaiting work from outside the graph

A caller on another thread, such as a request handler or a tool host, awaits graph.DispatchAsync (fun () -> ...). The work runs in the graph's consistency domain, as Dispatch work does, and the task completes with its result or faults with its exception. An optional CancellationToken cancels the item while it waits in the inbox. Disposing the graph faults every item still waiting with ObjectDisposedException.

let increment (graph: Graph) (count: Signal<int>) (cancel: CancellationToken) : Task<int> =
    task {
        // The work runs on the graph's thread. Called from another thread, the await resumes on the thread pool.
        let! next =
            graph.DispatchAsync ((fun () ->
                count.Value <- count.Value + 1
                count.Value), cancel)

        return next
    }

The sample runs the owner on a dedicated thread that drains its inbox when its dispatcher wakes it, and reads a memo, writes a signal and catches a failure from the thread pool. Contracts lists every case.

Where tracking state lives

Tracking must not follow an await into its continuation. Code that resumes after an await is outside the synchronous window in which reads are recorded. For this reason, the tracking context is held by the graph and scoped to a single run. The ambient graph that the create* functions use flows with the async context of graph.Activate (). A guarded graph is ambient on the activating thread only, so code on another thread must activate a graph before it creates nodes. An activation inside an async body is invisible to other work on the activating thread while the body is suspended.

Hopping synchronisation contexts

Implemented. A Blazor Server circuit serialises work but runs it on different pool threads. Under Guarded, a graph owned by one of those threads rejects writes that arrive on the next one. Under Unchecked, the writes land but the thread check is gone. ThreadAffinity.Serialised accepts an entry from any thread on the context captured at construction, and raises when a second thread enters while one is inside the graph. Settles and dispatched work from outside the graph are queued to the context, so they run serialised with rendering. Activation flows with the execution context, as under Unchecked. Contracts lists the rules, and Blazor Server shows one graph per circuit. Blazor WebAssembly is single-threaded and works under the default.

Ownership and disposal

Implemented.

A pending node has to outlive the loss of its last subscriber, or its flight would complete into a disposed node. On .NET, an in-flight task also holds a strong reference to the node. Teardown is therefore deterministic:

  • Owned computations belong to an owner scope. createRoot creates a scope, and disposing it disposes the owned nodes under it. Signals and async sources are unowned.
  • Disposing an async memo cancels its flight's token.
  • A node that is Pending when it is disposed becomes Failed with ObjectDisposedException, so nothing waits on it indefinitely.

Contracts gives the owner of each node an API returns, and of each node created inside a computation.

One source, two targets

The core is written in F# that compiles both on .NET and through Fable to JavaScript. It shares the graph model, with platform-specific implementations and some construction-time GraphOptions. Important differences include:

  • Equality. The default JsIdentityPolicy matches JavaScript's ===. It compares primitives and strings by value and reference types by reference. On .NET it compares other value types by value, and float uses IEEE equality, so a nan write is not cut off. StructuralPolicy uses structural equality where a native build wants it.
  • Exceptions. Suspension uses a throw on both targets, so user code looks the same on both. This is one more reason resumable code was rejected: Fable cannot compile it, so it would have produced code that builds on one target and not on the other.
  • Threads. A Fable graph runs in one JavaScript execution context and ignores the .NET dispatcher machinery.

Compile browser applications with Fable and render them with Ranvier.View.Dom. Server and browser components share the same Ranvier.View templates.

Outside the scope of the core

The Ranvier package owns reactive computation. Ranvier.View adds reusable templates, browser mounting, server HTML and hydration. Each binding updates the DOM part that reads it. Native hosts such as Avalonia, WPF, MAUI and Blazor render through their own toolkits.

Edit this page