From 3e0dd930e0a3a31a6723af242226e2c61e602c04 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Cl=C3=A9ment=20Pit-Claudel?= <clement.pit-claudel@epfl.ch>
Date: Sat, 30 Nov 2024 02:07:50 +0100
Subject: [PATCH] client: Clean up implementation of Pause action and fix
 double wait

* js/src/main/scala/cs214/webapp/client/StateMachineClientApp.scala:
  (processing):
    New Boolean indicating that an event loop is running.
  (timeout, setCooldown, tryProcessNextAction):
    Remove.
  (processActions):
    New function: start an event loop, setting `processing` to `true` while
    traversing `actionsQueue`.  Recurse through a future to allow individual
    actions to delay the next iteration of the event loop.
  (processAction):
    Return a `Future` to let individual actions handle timeouts.
  (after):
    New function: Expose `setTimeout` as a Future.

Reported-by: Simon Lefort <androz2091@gmail.com>
---
 .../webapp/client/StateMachineClientApp.scala | 64 ++++++++-----------
 1 file changed, 25 insertions(+), 39 deletions(-)

diff --git a/js/src/main/scala/cs214/webapp/client/StateMachineClientApp.scala b/js/src/main/scala/cs214/webapp/client/StateMachineClientApp.scala
index 8f70c6b..3cab0db 100644
--- a/js/src/main/scala/cs214/webapp/client/StateMachineClientApp.scala
+++ b/js/src/main/scala/cs214/webapp/client/StateMachineClientApp.scala
@@ -7,6 +7,9 @@ import scala.util.{Failure, Success}
 import org.scalajs.dom
 import scalatags.JsDom.all.Frag
 
+import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue
+import scala.concurrent.{Future, Promise}
+
 type Target = dom.Element
 
 abstract class WSClientApp extends ClientApp:
@@ -83,58 +86,41 @@ abstract class StateMachineClientAppInstance[Event, View](
       case Failure(msg)              => WebClient.crash(Exception(msg))
       case Success(Failure(msg))     => renderError(msg.getMessage)
       case Success(Success(actions)) => actionsQueue.enqueueAll(actions)
-    tryProcessNextAction()
+    tryProcessActionsQueue()
 
-  /** The views to render */
+  /** Actions waiting to be processed. */
   private val actionsQueue: mutable.Queue[Action[ujson.Value]] = mutable.Queue()
+  private var processing = false
 
-  /** Minimal cooldown time between the render of two views */
-  private var actionCooldownDelay = 0
-
-  /** The timestamp for the last render of a view */
-  private var lastActionAppliedMs = System.currentTimeMillis()
-
-  /** A call to the [[tryProcessNextAction()]] function scheduled */
-  private var timeout: Option[SetTimeoutHandle] = None
+  private def tryProcessActionsQueue(): Unit =
+    if !processing then processActions()
 
-  /** Sets the [[actionCooldownDelay]] of the client to the specified value */
-  private def setCooldown(ms: Int): Unit = actionCooldownDelay = ms
-
-  /** Apply the next action from [[actionsQueue]] if [[actionCooldownDelay]]
-    * permits it.
-    */
-  private def tryProcessNextAction(): Unit =
-    // If there are still views to render and if no call to this function is scheduled
-    if actionsQueue.nonEmpty && timeout.isEmpty then
-      // Then check if we are out of the cooldown delay
-      if System.currentTimeMillis() - lastActionAppliedMs > actionCooldownDelay then
-        // We can render the view, and dequeue it
-        processAction(actionsQueue.dequeue())
-        // Continue try emptying the queue
-        tryProcessNextAction()
-      else
-        // We still have to wait, put a timeout to call the function later
-        timeout = Some(setTimeout(actionCooldownDelay) {
-          // First remove the timeout so that, if necessary,
-          // the next function call can create a new one
-          timeout = None
-          // Then try to render next views
-          tryProcessNextAction()
-        })
+  private def processActions(): Unit =
+    require(!processing)
+    def loop: Unit =
+      processing = actionsQueue.nonEmpty
+      if processing then
+        processAction(actionsQueue.dequeue()).andThen(_ => loop)
+    loop
 
   /** Execute a single action sent by the server. */
-  private def processAction(jsonAction: Action[ujson.Value]): Unit =
-    lastActionAppliedMs = System.currentTimeMillis()
-    setCooldown(0)
+  private def processAction(jsonAction: Action[ujson.Value]): Future[Unit] =
     jsonAction match
       case Action.Alert(msg) =>
         dom.window.alert(msg)
+        after(0)
       case Action.Pause(durationMs) =>
-        setCooldown(durationMs)
+        after(durationMs)
       case Action.Render(js) =>
-        // Step1: The client receives the view sent by the server here
         wire.viewFormat.decode(js) match
           case Failure(exception) => renderError(exception.getMessage)
           case Success(jsonView) =>
             target.replaceChildren:
               this.render(userId, jsonView).render
+        after(0)
+
+  /* Like `setTimeout(interval)`, but return a `Future`. */
+  private def after(interval: Double): Future[Unit] =
+    val pr = Promise[Unit]()
+    setTimeout(interval)(pr.success(()))
+    pr.future
-- 
GitLab