If the compute function of an AVal.custom creates an adaptive node and reads it with the token, the chain source -> inner -> outer is dropped by the next GC. After that, transacting the source does not mark outer out of date (outer.OutOfDate = false) and AVal.force outer returns the cached value. No exception.
FSharp.Data.Adaptive 1.2.26, .NET 9.0.15, workstation GC, Windows 11 x64.
Repro
repro.fsproj: net9.0, <ServerGarbageCollection>false</ServerGarbageCollection>, PackageReference FSharp.Data.Adaptive 1.2.26.
module Repro
open System
open FSharp.Data.Adaptive
let collect () =
GC.Collect()
GC.WaitForPendingFinalizers()
GC.Collect()
/// outer allocates the intermediate node during its own evaluation and reads it with the token
let nestedMap (source : cval<int>) =
AVal.custom (fun t ->
let inner = source |> AVal.map (fun v -> v * 10)
inner.GetValue t)
let nestedCustom (source : cval<int>) =
AVal.custom (fun t ->
let inner = AVal.custom (fun t -> source.GetValue t * 10)
inner.GetValue t)
/// same nesting, intermediate rooted by a variable the caller holds
let rooted (source : cval<int>) =
let inner = source |> AVal.map (fun v -> v * 10)
AVal.custom (fun t -> inner.GetValue t)
/// AVal.bind, which retains the inner node itself
let insideBind (source : cval<int>) =
source |> AVal.bind (fun v -> AVal.constant (v * 10))
/// control: reads the long-lived cval with the token
let direct (source : cval<int>) =
AVal.custom (fun t -> source.GetValue t * 10)
let run name build gcBetween =
let source = cval 1
let outer : aval<int> = build source
let before = AVal.force outer
if gcBetween then collect ()
transact (fun () -> source.Value <- 2)
let outdated = outer.OutOfDate
let after = AVal.force outer
printfn "%-28s gc=%-5b before=%-3d marked-out-of-date=%-5b after=%-3d expected=20 %s"
name gcBetween before outdated after (if after = 20 then "OK" else "STALE")
[<EntryPoint>]
let main _ =
run "direct (control)" direct true
run "AVal.map inside custom" nestedMap false
run "AVal.map inside custom" nestedMap true
run "custom inside custom" nestedCustom false
run "custom inside custom" nestedCustom true
run "rooted intermediate" rooted true
run "AVal.bind" insideBind true
0
direct (control) gc=true before=10 marked-out-of-date=true after=20 expected=20 OK
AVal.map inside custom gc=false before=10 marked-out-of-date=true after=20 expected=20 OK
AVal.map inside custom gc=true before=10 marked-out-of-date=false after=10 expected=20 STALE
custom inside custom gc=false before=10 marked-out-of-date=true after=20 expected=20 OK
custom inside custom gc=true before=10 marked-out-of-date=false after=10 expected=20 STALE
rooted intermediate gc=true before=10 marked-out-of-date=true after=20 expected=20 OK
AVal.bind gc=true before=10 marked-out-of-date=true after=20 expected=20 OK
No collection in between → correct. Intermediate rooted → correct. AVal.bind → correct. Only the node allocated during the custom's evaluation is lost.
Mechanism, as far as I can read it
Edges point forward only and are weak: IAdaptiveObject has Outputs : IWeakOutputSet and no inputs collection, so a consumer keeps its inputs alive only through references it holds itself. AVal.map/bind and adaptive { } store the input or inner node in a field; AVal.custom stores only its compute function, so a node allocated during evaluation is unreachable once evaluation returns.
Questions
- Is reading a node created during a token-driven evaluation intended to be unsupported?
- If so, can it be made loud rather than silent? At
GetValue(t) the callee knows whether it has any output other than the caller.
Found in PRo3D: packed annotation geometry built an AVal.custom inside another one's evaluation, and edited annotations stopped redrawing until an unrelated change marked the geometry dirty by another route.
If the compute function of an
AVal.customcreates an adaptive node and reads it with the token, the chainsource -> inner -> outeris dropped by the next GC. After that,transacting the source does not markouterout of date (outer.OutOfDate = false) andAVal.force outerreturns the cached value. No exception.FSharp.Data.Adaptive 1.2.26, .NET 9.0.15, workstation GC, Windows 11 x64.
Repro
repro.fsproj:net9.0,<ServerGarbageCollection>false</ServerGarbageCollection>,PackageReferenceFSharp.Data.Adaptive 1.2.26.No collection in between → correct. Intermediate rooted → correct.
AVal.bind→ correct. Only the node allocated during the custom's evaluation is lost.Mechanism, as far as I can read it
Edges point forward only and are weak:
IAdaptiveObjecthasOutputs : IWeakOutputSetand no inputs collection, so a consumer keeps its inputs alive only through references it holds itself.AVal.map/bindandadaptive { }store the input or inner node in a field;AVal.customstores only its compute function, so a node allocated during evaluation is unreachable once evaluation returns.Questions
GetValue(t)the callee knows whether it has any output other than the caller.Found in PRo3D: packed annotation geometry built an
AVal.custominside another one's evaluation, and edited annotations stopped redrawing until an unrelated change marked the geometry dirty by another route.