Repository F# setup
open System
open System.IO
open System.Threading
open System.Threading.Tasks
open Axial
open Axial.Layers
open Axial.Console
open Axial.FileSystem
open Axial.Hosting
open Axial.Hosting.Browser
open Axial.Hosting.Node
open Axial.PlatformService
open Axial.State
open Axial.Telemetry
open Axial.Telemetry.JavaScriptSupervision and Fiber Observability
A forked fiber whose handle is discarded can die silently.
Flow.fork returns a Fiber handle, and nothing stops a caller writing |> Flow.map ignore or let! _ = ... and dropping it. When such a fiber hits an unhandled exception, the runtime contains it as Exit.Failure (Cause.Die _), but nobody is awaiting that exit. Because Axial converts every exception into an Exit value, the underlying task never faults, so even .NET's TaskScheduler.UnobservedTaskException net never fires. Without help, that is a production failure with no log line.
Axial answers this with two pieces: Flow.supervise restarts background work that dies with defects, and the fiber observer reports the defects that still escape.
Both stay inside Axial's error model:
- Typed errors (
Cause.Fail) are untouched. They are domain values in yourFlow<'env, 'error, 'value>signature, not diagnostics. Supervision and observation apply only to defects (Cause.Die): bugs that escaped the typed channel. - Joining is the opt-out. A fiber whose outcome someone consumed (
Fiber.join,Fiber.interrupt) belongs to that caller; the runtime says nothing about it.
Restarting defects: Flow.supervise
supervise is the defect-channel sibling of Flow.retry:
retryre-runs typedCause.Failerrors and never touches defects.supervisere-runsCause.Diedefects and never touches typed errors or interruptions.
Shared setup
// Setup for the checked examples on this page.
open System
open System.IO
open System.Threading
open System.Threading.Tasks
open Axial
open Axial.Layers
open Axial.Console
open Axial.FileSystem
open Axial.Hosting
open Axial.Hosting.Browser
open Axial.Hosting.Node
open Axial.PlatformService
open Axial.State
open Axial.Telemetry
open Axial.Telemetry.JavaScript
/// Fails the docs test when an example's result differs from the value shown.
let shouldEqual expected actual =
if actual <> expected then failwithf "Expected %A but got %A" expected actual
SystemIOThreadingTasksAxialLayersConsoleFileSystemHostingBrowserNodePlatformServiceStateTelemetryJavaScriptshouldEqual: 'a -> 'a -> unitexpected: 'aactual: 'a(<>): 'T -> 'T -> boolStructural inequality The first parameter. The second parameter. The result of the comparison. 5 <> 5 // Evaluates to false 5 <> 6 // Evaluates to true [1; 2] <> [1; 2] // Evaluates to false
failwithf: Printf.StringFormat<'T,'Result> -> 'TPrint to a string buffer and raise an exception with the given result. Helper printers must return strings. The formatter. The formatted result. See Printf.failwithf (link: ) for examples.
let polls = ref 0
/// Crashes on its first two runs, as a worker does on a poison message, then succeeds.
let pollQueue : Flow<ClockEnvironment, string, int> =
Flow.delay (fun () ->
let run = Interlocked.Increment &polls.contents
if run <= 2 then Flow.die (InvalidOperationException "poison message") else Flow.ok run)
let restartPolicy =
Retry.schedule
{ Retry.defaults with
Retries = 4
Backoff = Backoff.Exponential(TimeSpan.FromMilliseconds 1.0, TimeSpan.FromMilliseconds 10.0) }
let reliableWorker : Flow<ClockEnvironment, string, int> = pollQueue |> Flow.supervise restartPolicy
polls: int refref: 'T -> 'T refCreate a mutable reference cell The value to contain in the cell. The created reference cell. let count = ref 0 // Creates a reference cell object with a mutable Value property count.Value // Evaluates to 0 count.Value <- 1 // Updates the value count.Value // Evaluates to 1
pollQueue: Flow<ClockEnvironment,string,int>Axial.Flow`3Represents a cold workflow that reads an environment, returns a typed result, and is executed explicitly through one of its execution members such as ToTask, ToAsync, or RunSynchronously. The type of the environment dependency. The type of the failure value. The type of the success value.
Axial.ClockEnvironmentAn environment containing only a clock, for timed flows with no other services.
stringAn abbreviation for the CLI type . Basic Types
intAn abbreviation for the CLI type . Basic Types
Axial.Flowdelay: (unit -> Flow<'env,'error,'value>) -> Flow<'env,'error,'value>Defers flow construction until execution time. A function that returns the flow to execute. A flow that lazily evaluates the factory when executed. let flow = Flow.delay (fun () -> Flow.succeed 42)
run: intSystem.Threading.InterlockedProvides atomic operations for variables that are shared by multiple threads.
Increment: byref<int> -> intIncrements a specified variable and stores the result, as an atomic operation. The variable whose value is to be incremented. The incremented value. The address of is a null pointer.
(~&): 'T -> byref<'T>Address-of. Uses of this value may result in the generation of unverifiable code. The input object. The managed pointer.
contents: 'TThe current value of the reference cell
(<=): 'T -> 'T -> boolStructural less-than-or-equal comparison The first parameter. The second parameter. The result of the comparison. 5 <= 1 // Evaluates to false 5 <= 5 // Evaluates to true [1; 5] <= [1; 6] // Evaluates to true
die: exn -> Flow<'env,'error,'value>Creates a defective flow that fails with an exception. The exception representing the defect. A flow that always dies with the provided exception. This is the public constructor for non-domain defects. Use fail for expected typed failures and die when the workflow should surface a bug or panic.
``.ctor``: string -> unitInitializes a new instance of the class with a specified error message. The message that describes the error.
ok: 'value -> Flow<'env,'error,'value>Creates a successful synchronous flow. The value to wrap in a successful flow. A flow that always succeeds with the provided value.
restartPolicy: Schedule<ClockEnvironment,exn,int>Axial.RetryModuleConstructors for .
schedule: Retry<'input> -> Schedule<'env,'input,int>Builds the schedule a retry record describes. The retry description. A schedule that emits the retry number, starting at 0. Thrown when Retries is negative or a delay is negative.
Axial.Retry`1A retry described with named fields: how many retries, how long to wait, and which inputs to retry. Build one from Retry.defaults with record update syntax and pass it to Flow.retry through Retry.schedule. It is a shorthand for the common case; anything a record cannot express (jitter, elapsed-time limits, fixed-rate runs) is written as a Schedule directly. The input the retry decision sees: the typed error for Flow.retry, the defect for Flow.supervise.
defaults: Retry<'input>Three retries with exponential backoff from 100 ms, capped at 10 s, retrying every input. fetch |> Flow.retry (Retry.schedule { Retry.defaults with Retries = 5; When = HttpError.isTransient })
Retries: intThe maximum number of retries after the first attempt; 3 allows 4 executions in total.
Backoff: BackoffThe delay before each retry.
Axial.BackoffHow long a waits between attempts.
ExponentialDoubles the delay before each retry, starting at initial and never exceeding max.
System.TimeSpanRepresents a time interval.
FromMilliseconds: float -> TimeSpanReturns a that represents a specified number of milliseconds. A number of milliseconds. An object that represents . is less than or greater than . -or- is . -or- is . is equal to .
reliableWorker: Flow<ClockEnvironment,string,int>(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
supervise: Schedule<'env,exn,'output> -> Flow<'env,'error,'value> -> Flow<'env,'error,'value>Restarts a flow that terminates with an unexpected defect, according to a schedule. The defect-channel sibling of retry: retry re-runs typed Cause.Fail errors and never touches defects, while supervise re-runs Cause.Die defects and never touches typed errors or interruptions. The schedule sees the first defect of each failed run. Each attempt runs in its own child scope, closed before the next attempt starts, so finalizers registered by a failed attempt are released instead of accumulating. Re-evaluation only resets state that lives inside the flow itself; mutable state in the environment is not restored. When the schedule stops, the final defect propagates as the flow's exit. Decides, from each defect, whether to restart and after what delay. The flow to supervise. A flow that re-evaluates Cause.Die outcomes while the schedule allows it. worker |> Flow.supervise (Retry.schedule { Retry.defaults with Retries = 5 })
flow {
let! fiber = Flow.fork reliableWorker
return! Fiber.join fiber
}
|> Flow.run (ClockEnvironment Clock.live)
|> shouldEqual (Exit.Success 3)
flow: FlowBuilderThe universal flow { } computation expression.
fiber: Fiber<string,int>Axial.Flowfork: Flow<'env,'error,'value> -> Flow<'env,'none,Fiber<'error,'value>>Starts a flow in a new fiber without waiting for it to complete. Forking turns a cold flow description into hot child work and returns a handle that can later be joined or interrupted. Prefer zipPar or race when the caller only needs a simple parallel composition. Wait for the handle with Fiber.join or Fiber.await, and stop it with Fiber.interrupt. The flow to fork. A flow that produces a handle.
reliableWorker: Flow<ClockEnvironment,string,int>Axial.FiberModuleOperations on a running , the handle returned by Flow.fork. Every operation returns a flow; nothing waits or interrupts until that flow runs. Reading a fiber's outcome through join, await, or interrupt marks it observed, so a defect it died with is not also reported as unobserved.
join: Fiber<'error,'value> -> Flow<'env,'error,'value>Waits for a fiber and returns its value, failing the same way the fiber failed. Joining preserves the child's error channel: a Cause.Fail becomes the same typed error, and interruption and defects remain interruption and defects. Use await to inspect the outcome instead. The fiber to join. A flow that completes with the fiber's value. let loadProfile : Flow<unit, string, string> = Flow.ok "profile" let loadOrders : Flow<unit, string, int list> = Flow.ok [ 1; 2 ] let page = flow { let! fiber = Flow.fork loadProfile let! orders = loadOrders let! profile = Fiber.join fiber return profile, orders }
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
run: 'env -> Flow<'env,'error,'value> -> Exit<'value,'error>Runs the workflow and blocks until the final exit is available. The environment used by the workflow. The workflow to run. The final workflow exit. let exit = workflow |> Flow.run environment
``.ctor``: IClock -> ClockEnvironmentAxial.PlatformService.ClockHelpers for the clock service.
live: IClockCreates a live clock backed by and a monotonic timer.
shouldEqual: 'a -> 'a -> unitAxial.Exit`2Represents the final outcome of a workflow execution. The type of the success value. The type of the domain-specific failure value.
SuccessThe workflow completed successfully.
supervise takes the same Schedule as retry, but its input is the defect exception, so Schedule.whileInput or a Retry record's When can decide which crashes are worth a restart. Bound the restarts with Retries or Schedule.recursAtMost (or a time budget with Schedule.upTo) so a crash loop eventually surfaces. When the schedule stops, the final defect propagates as the flow's exit.
Two semantics worth knowing:
- Each attempt runs in its own child scope. Finalizers registered by a failed attempt run before the next attempt starts, so a supervised worker that acquires resources does not leak one acquisition per restart. The successful attempt's scope stays open until the enclosing scope closes, so a value it returns is still usable.
- Restart is not an Erlang restart. Re-evaluating the cold flow resets state that lives inside the flow. If your environment holds mutable state that the crashed attempt corrupted, restarting does not heal it.
Deliberate fire-and-forget: Flow.forkDetached
If a background fiber's outcome genuinely does not matter, say so at the call site:
let bestEffortCacheWarmup : Flow<ClockEnvironment, string, unit> = Flow.die (InvalidOperationException "cache warmup failed")
let startWarmup : Flow<ClockEnvironment, string, unit> =
flow {
let! _fiber = Flow.forkDetached bestEffortCacheWarmup
return ()
}
bestEffortCacheWarmup: Flow<ClockEnvironment,string,unit>Axial.Flow`3Represents a cold workflow that reads an environment, returns a typed result, and is executed explicitly through one of its execution members such as ToTask, ToAsync, or RunSynchronously. The type of the environment dependency. The type of the failure value. The type of the success value.
Axial.ClockEnvironmentAn environment containing only a clock, for timed flows with no other services.
stringAn abbreviation for the CLI type . Basic Types
unitThe type 'unit', which has only one value "()". This value is special and always uses the representation 'null'. Basic Types
Axial.Flowdie: exn -> Flow<'env,'error,'value>Creates a defective flow that fails with an exception. The exception representing the defect. A flow that always dies with the provided exception. This is the public constructor for non-domain defects. Use fail for expected typed failures and die when the workflow should surface a bug or panic.
``.ctor``: string -> unitInitializes a new instance of the class with a specified error message. The message that describes the error.
startWarmup: Flow<ClockEnvironment,string,unit>flow: FlowBuilderThe universal flow { } computation expression.
_fiber: Fiber<string,unit>forkDetached: Flow<'env,'error,'value> -> Flow<'env,'none,Fiber<'error,'value>>Starts a flow in a new fiber that is deliberately never awaited. The explicit fire-and-forget: the fiber counts as observed from birth, so a defect it dies with is never reported as an unobserved defect through the runtime's fiber observer. Use this instead of discarding a Flow.fork handle when silence is intended; a discarded fork handle whose fiber dies of a defect is reported. The flow to fork. A flow that produces a handle that can still be joined or interrupted.
A detached fiber counts as observed from birth, so a defect it dies with is never reported as unobserved. Use it instead of discarding a Flow.fork handle: a discarded fork handle whose fiber dies of a defect is reported.
The safety net: FiberObserver
FiberObserver is a record of lifecycle hooks installed once at the application edge and carried implicitly to every descendant fork:
let unobserved = ResizeArray<string>()
let observer =
{ FiberObserver.none with
OnUnobservedDefect = fun _ defect -> lock unobserved (fun () -> unobserved.Add defect.Message) }
/// Detaches one failing fiber and discards the handle of another.
let application : Flow<ClockEnvironment, string, unit> =
flow {
do! startWarmup
let! _ = Flow.fork (Flow.die (InvalidOperationException "lost order") : Flow<ClockEnvironment, string, unit>)
do! Flow.sleep (TimeSpan.FromMilliseconds 20.0)
}
|> Flow.scoped
unobserved: ResizeArray<string>``.ctor``: unit -> unitInitializes a new instance of the class that is empty and has the default initial capacity.
stringAn abbreviation for the CLI type . Basic Types
observer: FiberObserverAxial.FiberObserverRuntime hooks observing fiber lifecycle events for diagnostics and telemetry. Installed once at the application edge with Flow.withFiberObserver and carried implicitly to every descendant fork. All hooks default to no-ops, receive only diagnostic data (FiberMetadata and defect exceptions, never typed exits), and must not throw; exceptions raised by hooks are swallowed so a diagnostics hook can never alter a fiber's outcome.
Axial.FiberObserverModuleStandard fiber observers.
none: FiberObserverThe default observer: every hook is a no-op.
OnUnobservedDefect: FiberMetadata option -> exn -> unitA Cause.Die defect became unobservable: a forked fiber died unobserved and no observation can happen anymore, or the runtime discarded a race/timeout loser's exit. The metadata is absent for discarded race/timeout losers, which are executions rather than fibers.
defect: exnlock: 'Lock -> (unit -> 'T) -> 'TExecute the function as a mutual-exclusion region using the input value as a lock. The object to be locked. The action to perform during the lock. The resulting value. open System.Linq /// A counter object, supporting unlocked and locked increment type TestCounter () = let mutable count = 0 /// Increment the counter, unlocked member this.IncrementWithoutLock() = count <- count + 1 /// Increment the counter, locked member this.IncrementWithLock() = lock this (fun () -> count <- count + 1) /// Get the count member this.Count = count let counter = TestCounter() // Create a parallel sequence to that uses all our CPUs (seq {1..100000}).AsParallel() .ForAll(fun _ -> counter.IncrementWithoutLock()) // Evaluates to a number between 1-100000, non-deterministically because there is no locking counter.Count let counter2 = TestCounter() // Create a parallel sequence to that uses all our CPUs (seq {1..100000}).AsParallel() .ForAll(fun _ -> counter2.IncrementWithLock()) // Evaluates to 100000 deterministically because the increment to the counter object is locked counter2.Count
Add: string -> unitAdds an object to the end of the . The object to be added to the end of the . The value can be for reference types.
Message: stringGets a message that describes the current exception. The error message that explains the reason for the exception, or an empty string ("").
application: Flow<ClockEnvironment,string,unit>Axial.Flow`3Represents a cold workflow that reads an environment, returns a typed result, and is executed explicitly through one of its execution members such as ToTask, ToAsync, or RunSynchronously. The type of the environment dependency. The type of the failure value. The type of the success value.
Axial.ClockEnvironmentAn environment containing only a clock, for timed flows with no other services.
unitThe type 'unit', which has only one value "()". This value is special and always uses the representation 'null'. Basic Types
flow: FlowBuilderThe universal flow { } computation expression.
startWarmup: Flow<ClockEnvironment,string,unit>Axial.Flowfork: Flow<'env,'error,'value> -> Flow<'env,'none,Fiber<'error,'value>>Starts a flow in a new fiber without waiting for it to complete. Forking turns a cold flow description into hot child work and returns a handle that can later be joined or interrupted. Prefer zipPar or race when the caller only needs a simple parallel composition. Wait for the handle with Fiber.join or Fiber.await, and stop it with Fiber.interrupt. The flow to fork. A flow that produces a handle.
die: exn -> Flow<'env,'error,'value>Creates a defective flow that fails with an exception. The exception representing the defect. A flow that always dies with the provided exception. This is the public constructor for non-domain defects. Use fail for expected typed failures and die when the workflow should surface a bug or panic.
``.ctor``: string -> unitInitializes a new instance of the class with a specified error message. The message that describes the error.
sleep: TimeSpan -> Flow<'env,'error,unit>Suspends the flow for the specified duration, observing cancellation. The duration to sleep. A flow that completes after the specified delay, or is interrupted if cancelled first.
System.TimeSpanRepresents a time interval.
FromMilliseconds: float -> TimeSpanReturns a that represents a specified number of milliseconds. A number of milliseconds. An object that represents . is less than or greater than . -or- is . -or- is . is equal to .
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
scoped: Flow<'env,'error,'value> -> Flow<'env,'error,'value>Runs a flow in a child scope and closes that scope before returning. Resources and fibers acquired inside the flow are released after success, typed failure, defect, or interruption without waiting for the surrounding application scope to close.
application |> Flow.withFiberObserver observer |> Flow.run (ClockEnvironment Clock.live) |> shouldEqual (Exit.Success())
List.ofSeq unobserved |> shouldEqual [ "lost order" ]
application: Flow<ClockEnvironment,string,unit>(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Axial.FlowwithFiberObserver: FiberObserver -> Flow<'env,'error,'value> -> Flow<'env,'error,'value>Installs runtime fiber-lifecycle hooks for diagnostics and telemetry. The observer is carried implicitly to every fiber forked inside , so installing it once at the application edge covers all descendant background work. Hooks receive diagnostic data only and cannot alter any fiber's outcome; exceptions they throw are swallowed. The lifecycle hooks. Start from FiberObserver.none and override what you need. The source flow. A flow that runs with the supplied observer in the ambient runtime context.
observer: FiberObserverrun: 'env -> Flow<'env,'error,'value> -> Exit<'value,'error>Runs the workflow and blocks until the final exit is available. The environment used by the workflow. The workflow to run. The final workflow exit. let exit = workflow |> Flow.run environment
``.ctor``: IClock -> ClockEnvironmentAxial.PlatformService.ClockHelpers for the clock service.
live: IClockCreates a live clock backed by and a monotonic timer.
shouldEqual: 'a -> 'a -> unitAxial.Exit`2Represents the final outcome of a workflow execution. The type of the success value. The type of the domain-specific failure value.
SuccessThe workflow completed successfully.
Microsoft.FSharp.Collections.ListModuleContains operations for working with values of type . Operations for collections such as lists, arrays, sets, maps and sequences. See also F# Collection Types in the F# Language Guide.
ofSeq: 'T seq -> 'T listBuilds a new list from the given enumerable object. The input sequence. The list of elements from the sequence. let inputs = seq { 1; 2; 5 } inputs |> List.ofSeq Evaluates to [ 1; 2; 5 ]. This is an O(n) operation, where n is the length of the sequence.
unobserved: ResizeArray<string>The discarded fork's defect was reported when its scope closed. The detached warmup's was not.
The hooks:
OnStart: a fiber was forked; receives the child'sFiberMetadata.OnEnd: a fiber settled;FiberMetadata.StatusdistinguishesSucceeded/Failed/Interrupted, and the defect exception (if the fiber died of one) is passed alongside. This fires for every fiber, observed or not, so use it for metrics.OnUnobservedDefect: a defect became unobservable: a forked fiber died and nobody ever consumed its outcome, or the runtime itself discarded aFlow.race/ timeout loser's exit (those never had a handle at all, so their metadata isNone).
All hooks default to no-ops, receive diagnostic data only, and cannot alter any fiber's outcome; exceptions they throw are swallowed.
When does "unobserved" fire?
Whether a fiber will ever be joined is only knowable retroactively, so the runtime reports at three moments:
- Immediately, for race/timeout losers: the runtime knows at the discard site that no one can ever see that exit.
- When the forking scope closes, for fibers that settled with a defect and were never observed. Scope close is a fixed point in the program, so the report arrives at a predictable time.
- When a discarded handle is garbage-collected, as a best-effort net for forks made inside long-lived scopes (the same mechanism family as
UnobservedTaskException; timing depends on GC).
Each defect is reported at most once, whichever mechanism gets there first.
Note the interaction with supervise: a supervised flow that exhausts its restarts still settles with Cause.Die, so a discarded supervised fiber still reaches the net. Supervision reduces how often the net is needed; it does not replace it.
Telemetry integration
Axial.Telemetry ships a ready-made observer that records defects on the Axial activity source:
let tracedApplication : Flow<ClockEnvironment, string, unit> =
application |> FiberTelemetry.observe // = Flow.withFiberObserver FiberTelemetry.observer
tracedApplication: Flow<ClockEnvironment,string,unit>Axial.Flow`3Represents a cold workflow that reads an environment, returns a typed result, and is executed explicitly through one of its execution members such as ToTask, ToAsync, or RunSynchronously. The type of the environment dependency. The type of the failure value. The type of the success value.
Axial.ClockEnvironmentAn environment containing only a clock, for timed flows with no other services.
stringAn abbreviation for the CLI type . Basic Types
unitThe type 'unit', which has only one value "()". This value is special and always uses the representation 'null'. Basic Types
application: Flow<ClockEnvironment,string,unit>(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Axial.Telemetry.JavaScript.FiberTelemetryFiber-lifecycle observability on the installed OpenTelemetry tracer: the JavaScript counterpart of the .NET package's FiberTelemetry, with the same span names and attribute vocabulary.
observe: Flow<'env,'error,'value> -> Flow<'env,'error,'value>Installs the defect-only telemetry fiber observer, typically once at the application edge. The source flow. A flow whose forked fibers report defects through the installed tracer.
Every fiber that settles with a defect produces an axial.flow.fiber.defect error span, and every unobservable defect produces an axial.flow.fiber.unobserved_defect error span, tagged with fiber id, parent id, status, and OpenTelemetry-convention exception tags.
Logging
Axial.Hosting ships the Microsoft.Extensions.Logging wiring: FiberLogging.observe logger logs
fiber defects as errors and unobserved defects as critical entries, with the exception attached. Observers
compose, so telemetry and logging stack from one edge install:
let observeWithLogging (logger: Microsoft.Extensions.Logging.ILogger) (workflow: Flow<ClockEnvironment, string, unit>) =
workflow
|> Flow.withFiberObserver (FiberObserver.compose FiberTelemetry.observer (FiberLogging.observer logger))Platform notes
Supervision and the observer hooks are pure F# and behave identically under Fable. The detection mechanisms differ slightly by platform: the scope-close sweep and timeout-loser reporting work everywhere, and the GC net is .NET-only.

