ScheduleSolution callback not being invoked after switching documents

Hi @DavidRutten et al,
I’m building a set of grasshopper components that update frequently from a background thread(s) and I am having some issues with ScheduleSolution(int interval) in some scenarios.

In this reproduction I have a timer ticking at a given interval, which calls ScheduleSolution and should expire the component and update the document in the near future. The component message displays the number of times we call ScheduleSolution versus the number of times the delegate gets called, and the data record and panel show the total number of actual component updates (which should be less than the number of delegates called, since multiple may be called for one schedule).

Please see here for the test component code as a minimal reproducible example.

This also reproduces with a C# component (.gh) (5.2 KB)

This is the expected behaviour, where the number of callbacks is equal (or one or two ticks behind) the number of timer ticks:

Fig 1:
expected_behaviour

However, when I switch documents (or create new) and return the solutions still get scheduled but the callbacks are never executed (Fig 2).

Fig 2:
error_behaviour

When the document is manually updated, the scheduled delegates do get updated (Fig 3) - so it seems like they are getting correctly added to the queue, but the document isn’t getting a new solution called on schedule.

Fig 3:
error_behaviour_2

Lastly, It seems like certain combinations (or perhaps timings?) of scheduling the solutions (Fig 4) can also cause this behavior, though less permanently and it is less reproducible than in the new document scenario (Fig 2).

Fig 4:
error_behaviour_3

My first thoughts were to do with concurrency - but using a lock on the scheduling does not appear to make any difference, and I would have expected this to be handled via the BeginInvoke regardless. Inspecting the variables in VS shows that m_scheduleDelegates does add the delegates to the list, it simply never recomputes the document. Recomputing the document manually (i.e. updating a slider, clicking a button, enabling a component) will execute the scheduled delegates.

Do you know why this may be the case? I don’t get any unexpected diagnostics from VS.

The same code does produce expected behavior with ExpireSolution(true) instead of ScheduleSolution but of course this more or less freezes the UI indefinitely when the intervals are tight. This is the code.

Cheers
Cam

image

Note: The goal here isn’t to replicate a timer component or complete a regularly scheduled execution (in which case scheduling a solution from the SolveInstance might apply, but is instead to respond to events fired quickly from multiple external sources on separate threads.

Interestingly, this does not appear to break scheduling for the rest of the document. This component still works as expected with an interval of 1000, after scheduling has been broken. When this callback is received it updates and executes the other callbacks. However, the ‘broken’ component is still broken.

  private void RunScript(bool go, int interval, ref object A)
  {
    if (go)
    {
      Component.Message = "Scheduled";
      this.GrasshopperDocument.ScheduleSolution(interval, (GH_Document.GH_ScheduleDelegate) ((d) =>
        {
        Component.Message = "Got Callback";
        Component.ExpirePreview(true);
        }));
    }
  }

However, adding another instance of the component still does not function as expected, which suggests an issue with how I am scheduling the solutions…

A perhaps related issue: it seems the document solution depth gets ‘stuck’ - this could be what is stopping the recompute. Even after deleting all the other components (just the C# component with SolutionDepth remains), it still outputs >1.

For anyone visiting this issue at a future time, I’ve worked around it by writing my own solution scheduler, which the relevant components use instead of GH_Document.ScheduleSolution.

The general idea is:

  • SCHEDULE_DELAY determines how long after requesting a solution the solution should be executed (at minimum). This is similar to the parameter in GH_Document.ScheduleSolution. It provides an interval for other components to also jump onto the same solution update.
  • BREATHING_ROOM determines when a new solution is allowed based on when ActiveCanvas.CanvasPaintEnd was executed after the last solution completed. This ensures that the GUI stays responsive. In effect, it also acts as a cap for the number of solutions per second, so for example BREATHING_ROOM=50 results in a maximum of 20 solutions per second. Reducing this number will result in potentially more frequent updates (hardware allowing) at the cost of UI responsiveness/FPS. The logic of the schedulerprohibits a solution being computed without allowing a single draw frame inbetween.
internal static class Scheduler
    {
        /// <summary>
        /// The delay between an update being requested and the document being refreshed. This allows more components to be collected into a scheduled solution
        /// </summary>
        private const int SCHEDULE_DELAY = 20;

        /// <summary>
        /// How long the UI should be active for before another update is scheduled. This is a delay after ActiveCanvas.CanvasPaintEnd.
        /// </summary>
        private const int BREATHING_ROOM = 20;

        /// <summary>
        /// The collection of components that should be expired when a new solution is computed, and their respective documents
        /// </summary>
        private static readonly Dictionary<GH_Document, Dictionary<Guid, PendingObjectUpdate>> pendingComponents = new Dictionary<GH_Document, Dictionary<Guid, PendingObjectUpdate>>();

        private struct PendingObjectUpdate : IEquatable<PendingObjectUpdate>
        {
            public IGH_ActiveObject Object;
            public bool ExpiryRequired;
            public List<Action> Actions;

            public PendingObjectUpdate(IGH_ActiveObject obj, bool requireExpiry=true)
            {
                Object = obj;
                ExpiryRequired = requireExpiry;
                Actions = null;
            }

            public void AddAction(Action action)
            {
                if (action == null) return;
                if (Actions == null) Actions = new List<Action>();
                Actions.Add(action);
            }

            public void ExecuteActions()
            {
                if (Actions != null)
                {
                    foreach (var action in Actions)
                    {
                        try
                        {
                            action();
                        }
                        catch (Exception ex)
                        {
                            Log($"An exception occurred while running an action for {Object?.NickName ?? Object.ToString()}");
                            Log(ex);
                        }
                    }
                }
            }

            public override int GetHashCode()
            {
                return Object.InstanceGuid.GetHashCode();
            }

            public override bool Equals(object obj)
            {
                if (obj is PendingObjectUpdate other) return Equals(other);
                return false;
            }

            public bool Equals(PendingObjectUpdate other)
            {
                return other.Object.InstanceGuid == this.Object.InstanceGuid;
            }
        }

        static Scheduler()
        {
            Grasshopper.Instances.ActiveCanvas.DocumentChanged += ActiveCanvas_DocumentChanged;
            Grasshopper.Instances.ActiveCanvas.CanvasPaintEnd += ActiveCanvas_CanvasPaintEnd;
            SubscribeToDocument(Grasshopper.Instances.ActiveCanvas.Document);
        }

        private static readonly object m_lock = new object();

        private static bool m_isUpdateScheduled = false;

        /// <summary>
        /// Schedules an action to be executed with the solution but does NOT expire the component
        /// </summary>
        /// <param name="component">The component executing the action</param>
        /// <param name="action">The action to execute</param>
        public static void ScheduleAction(IGH_ActiveObject component, Action action)
        {
            ScheduleComponentUpdate(component, action, false);
        }

        /// <summary>
        /// Schedules a component to be updated on the next solution
        /// </summary>
        /// <param name="component">The component that requires an update</param>
        /// <param name="action">An additional action (optional) to be executed before expiry</param>
        /// <param name="requireExpiry">Whether the component should expire with the update. Defaults to true</param>
        public static void ScheduleComponentUpdate(IGH_ActiveObject component, Action action=null, bool requireExpiry=true)
        {
            if (!(component.OnPingDocument() is GH_Document doc)) return;

            if (!doc.Enabled) return;
            if (Grasshopper.Instances.ActiveCanvas?.Document != doc) return;

            // Add this component to the list of components waiting for updates
            lock (m_lock)
            {
                if (!pendingComponents.TryGetValue(doc, out Dictionary<Guid, PendingObjectUpdate> componentList))
                {
                    pendingComponents[doc] = componentList = new Dictionary<Guid, PendingObjectUpdate>();
                }

                if (!componentList.TryGetValue(component.InstanceGuid, out PendingObjectUpdate update))
                {
                    update = new PendingObjectUpdate(component, requireExpiry);
                    componentList[component.InstanceGuid] = update;
                }

                if (action != null)
                {
                    update.AddAction(action);
                }

                // No task is current scheduled, make one
                if (m_isUpdateScheduled == false)
                {
                    m_isUpdateScheduled = true;
                    ScheduleNewSolution(doc);
                }
            }
        }

        private static async void ScheduleNewSolution(GH_Document doc)
        {
            if (!m_isUpdateScheduled) return;

            await Task.Factory.StartNew(async () =>
            {
                // Wait for either SCHEDULE_DELAY or until BREATHING_ROOM has expired since the last canvas paint
                var timeToNextAllowedUpdate = m_nextAllowedUpdate == default ? -1 : (m_nextAllowedUpdate - DateTime.Now).TotalMilliseconds;
                int waitTime = (int)Math.Max(SCHEDULE_DELAY, timeToNextAllowedUpdate);

                await Task.Delay(waitTime);

                if (!m_isUpdateScheduled) return;

                // Jump over to main thread
                Grasshopper.Instances.DocumentEditor?.BeginInvoke((Action)(() =>
                {
                    ComputePendingUpdates(doc);
                    doc.NewSolution(false);
                }));
            });
        }

        private static DateTime m_nextAllowedUpdate = default;
        private static bool m_isWaitingForPaint = false;

        public static bool HasSolutionsScheduled(GH_Document doc)
        {
            if (doc == null) return false;
            lock (m_lock)
            {
                if (pendingComponents.TryGetValue(doc, out Dictionary<Guid, PendingObjectUpdate> pending))
                {
                    return pending.Count > 0;
                }
            }
            return false;
        }

        private static void Log(object obj)
        {
            Rhino.RhinoApp.WriteLine(obj.ToString());
        }

        private static void ActiveCanvas_DocumentChanged(Grasshopper.GUI.Canvas.GH_Canvas sender, Grasshopper.GUI.Canvas.GH_CanvasDocumentChangedEventArgs e)
        {
            UnsubscribeToDocument(e.OldDocument);
            SubscribeToDocument(e.NewDocument);
        }

        private static bool ComputePendingUpdates(GH_Document doc)
        {
            bool did_update = false;
            lock (m_lock)
            {
                // Expire the relevant components
                if (pendingComponents.TryGetValue(doc, out Dictionary<Guid, PendingObjectUpdate> pendingUpdates) && pendingUpdates.Count > 0)
                {
                    foreach (var kvp in pendingUpdates)
                    {
                        var update = kvp.Value;

                        if (update.Object.OnPingDocument() == doc)
                        {
                            update.ExecuteActions();
                            if (update.ExpiryRequired)
                            {
                                update.Object.ExpireSolution(false);
                            }
                            did_update = true;
                        }
                    }
                    pendingUpdates.Clear();
                }
                m_isUpdateScheduled = false;
            }
            return did_update;
        }

        private static void SubscribeToDocument(GH_Document doc)
        {
            if (doc == null) return;
            doc.SolutionEnd += Doc_SolutionEnd;
        }

        private static void Doc_SolutionEnd(object sender, GH_SolutionEventArgs e)
        {
            m_isWaitingForPaint = true;

        }
        private static void ActiveCanvas_CanvasPaintEnd(Grasshopper.GUI.Canvas.GH_Canvas sender)
        {
            if (m_isWaitingForPaint)
            {
                m_nextAllowedUpdate = DateTime.Now + TimeSpan.FromMilliseconds(BREATHING_ROOM);
                m_isWaitingForPaint = false;
            }
        }

        private static void UnsubscribeToDocument(GH_Document doc)
        {
            if (doc == null) return;
            doc.SolutionEnd -= Doc_SolutionEnd;
        }
    }