"Semi-eager realization of lazy sequences in ClojureScript"

April 21, 2017

asciinema player represents "frames" as a lazy sequence of screen states.

It starts by fetching asciicast JSON file, and extracts STDOUT print events from it. They form a vector like this:

(def stdout-events [
  [1.2 "hell"]
  [0.1 "o"]
  [6.2 " "]
  [1.0 "world"]
  [2.0 "!"]
  ...
])

To get a sequence of "frames" we run reductions function over it, with blank terminal (vt) as initial value:

(defn reduce-vt [[_ vt] [curr-time str]]
  [curr-time (vt/feed-str vt str)])

(let [vt (vt/make-vt width height)]
  (reductions reduce-vt [0 vt] stdout-events))

The details of the above code are not really important. The important thing is that the result of reductions is a lazy sequence. The player consumes this sequence as the playback progresses, sleeping proper amount of time (the number in stdout-events items) between screen updates.

When the sequence is consumed, reduce-vt function interprets text with control sequences (str arg in reduce-vt) and applies screen transformations to terminal model (vt). Usually this is not computationally intensive, but can be pretty heavy for more colorful, animated recordings. In such cases the animation stutters.

Also, when you skip forward to further position in the recording the player needs to consume all items from this lazy sequence up to the point you requested. This often requires lots of work, because computation for all not realized items adds up. In some cases it's visible as UI lag - player doesn't respond to user input because it's busy interpreting tens of kilobytes of text.

Knowing that the sequence will eventually be consumed in full, why not pass it through doall right before the beginning of the playback to generate all screen states upfront? That would certainly help with stuttering and made seeking blazing fast, but it would block the UI thread for couple of seconds or more. Not quite acceptable solution.

Would be great if we could semi-eagerly realize the lazy sequence piece by piece when the browser is idle during playback (e.g. when the player waits for time to pass before displaying next screen contents).

Cooperative scheduling to the rescue! requestIdleCallback, a younger sibling of requestAnimationFrame, allows code execution to be scheduled when the browser has nothing to do. Perfect fit for the problem.

Here's a requestIdleCallback based version of Clojure's dorun:

(defn dorun-when-idle [coll]
  (when-let [ric (aget js/window "requestIdleCallback")]
    (letfn [(make-cb [coll]
              (fn []
                (when (seq coll)
                  (ric (make-cb (rest coll))))))]
      (ric (make-cb coll)))))

(dorun-when-idle my-lazy-seq)

It goes through all sequence items, one item per every requestIdleCallback invocation. Clojure(Script)'s data structures are immutable, but lazy sequences cache their items internally. This allows us to walk through the sequence in one place, realizing it, while consuming it in another place, at slower pace.

The above function reminds me of little-known seque function, which realizes lazy sequence in a separate JVM thread, trying to stay ahead of the consumption. dorun-when-idle is pretty hungry, as it goes to the end of the sequence regardles of the consumption pace, however I think it should be possible to implement seque in ClojureScript through requestIdleCallback with the same semantics as in Clojure.

Anyway, this greatly improved animation smoothness and seeking responsiveness in asciinema player under major browsers, and it's already running on asciinema.org.

Read more about clojure, clojurescript.
blog comments powered by Disqus