Skip to content

Caret blink: RunAsync on an Infinite animation returns a faulted Task per instance ("Looping animations must not use the Run method") #49

Description

@ronnycohen

Description

RichTextBox starts its caret blink in the constructor path (InitializeAdornerElementsInitializeBlinkAnimation) with:

  • an Animation whose IterationCount = IterationCount.Infinite, and
  • blinkAnimation.RunAsync(_CaretRect); — fire-and-forget, the returned Task is neither awaited nor stored.

Avalonia refuses infinite animations on the Run/RunAsync path: instead of throwing, it returns a faulted taskTask.FromException(new InvalidOperationException("Looping animations must not use the Run method.")).

Consequences

  1. The caret never actually blinks (the animation never starts).
  2. Every new RichTextBox() produces a faulted, unobserved Task that later surfaces as TaskScheduler.UnobservedTaskException on the finalizer thread — with an empty stack trace (the exception is never thrown), which makes it very hard for consumers to trace back to this control.
  3. Consumers cannot observe or prevent it: the field is private and the task is discarded inside the constructor.

In our app (digital signage kiosk that periodically rebuilds screens containing several RichTextBox-based widgets) this generated ~940 telemetry events over 2 weeks across 4 devices before we could locate the source.

Repro

TaskScheduler.UnobservedTaskException += (s, e) => Console.WriteLine(e.Exception);
var rtb = new AvRichTextBox.RichTextBox();  // creates the faulted task
rtb = null;
GC.Collect(); GC.WaitForPendingFinalizers();
// -> AggregateException(InvalidOperationException("Looping animations must not use the Run method."))

Package: Simplecto.Avalonia.RichTextBox 1.9.3 (latest) · Avalonia 12.x (the RunAsync behavior is the same on 11.x). Still present on current main.

Suggested fix

Any of:

  • drive the blink through the styling/Apply path (which supports IterationCount.Infinite), or
  • run a finite animation restarted on completion, or
  • keep RunAsync but with a finite iteration count and observe the returned task.

Ideally only start blinking when the control actually has focus / the caret is visible — that would also avoid the cost for read-only usages.

Workaround (downstream)

We filter this exact message in our global UnobservedTaskException handler, which silences the noise but obviously doesn't restore the blink.

Thanks for the control — happy to test a fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions