Skip to content

AVal.custom silently serves a stale value when its compute function creates the node it reads (GC-dependent) #122

Description

@haraldsteinlechner

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

  1. Is reading a node created during a token-driven evaluation intended to be unsupported?
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions