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.HttpClient
open Axial.PlatformService
open Axial.Process
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.Runtime.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 (
Flow.join,Flow.interrupt) belongs to that caller; the runtime says nothing about it.
Restarting defects: Flow.Runtime.supervise
supervise is the defect-channel sibling of Flow.Runtime.retry:
retryre-runs typedCause.Failerrors and never touches defects.supervisere-runsCause.Diedefects and never touches typed errors or interruptions.
let reliableWorker =
pollQueueForever
|> Flow.Runtime.supervise
{ MaxAttempts = 5
Delay = fun attempt -> TimeSpan.FromSeconds(float attempt)
ShouldRestart = fun _ -> true }
let! fiber = Flow.fork reliableWorkerTwo 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.
- 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! _fiber = Flow.forkDetached bestEffortCacheWarmupThe safety net: FiberObserver
FiberObserver is a record of lifecycle hooks installed once at the application edge and carried implicitly to every descendant fork:
let observer =
{ FiberObserver.none with
OnUnobservedDefect = fun metadata defect ->
logger.LogError(defect, "Unobserved fiber defect (fiber {FiberId})",
metadata |> Option.map (fun m -> m.Id.Value)) }
application
|> Flow.withFiberObserver observer
|> fun workflow -> workflow |> Flow.run envOnStart— 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 — 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 — the deterministic, structured-concurrency boundary.
- 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:
open Axial.Telemetry
application
|> FiberTelemetry.observe // = Flow.withFiberObserver FiberTelemetry.observerLogging
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:
open Axial.Hosting
open Axial.Telemetry
application
|> Flow.withFiberObserver
(FiberObserver.compose FiberTelemetry.observer (FiberLogging.observer logger))Supervision and the observer hooks are pure F# and behave identically under Fable. The detection mechanisms differ slightly by platform: the scope-close sweep works everywhere; the GC net is .NET-only; and Fable's timeout cancels its loser without surfacing an exit, so timeout-loser reporting is .NET-only as well.

