leopard-js / leopard

Library for making Scratch-like projects with JavaScript.
https://leopardjs.com
MIT License
139 stars 26 forks source link

Match Scratch's script sequencing and execution semantics #212

Open adroitwhiz opened 1 month ago

adroitwhiz commented 1 month ago

Right now, Leopard's execution semantics seem to be rather simple--we step each script once per frame. This is close enough to what Scratch does in many cases, but in actuality it's a bit more complex. Scratch's logic is roughly:

Scratch also supports "turbo mode" semantics, which fit neatly into the above logic: if turbo mode is enabled, then we don't stop when a redraw is requested, only caring about the other two conditions (scripts are done, or time budget).

The main pitfall to watch out for when implementing this is busy-waiting. Right now, there's a lot of code that yields in a loop while waiting for a promise to resolve. This is fine if we only step threads once per frame, but in the case where no redraws have been requested or we're running in turbo mode, those loops will gladly spin until we've used our time budget, eating up a bunch of CPU time.

To avoid busy-waiting, I believe we need runtime support for yielding to promises--a script waiting for a promise is placed into a "sleeping" state where it will not be stepped until the promise resolves.

towerofnix commented 1 month ago

Out of curiosity... busy waiting is (unfortunately) a thing in Scratch, right? The easiest tell is that just clicking a "forever" block will cause dragging around blocks in the editor to suddenly feel choppy and slow.

Can you narrow down on which circumstances of busy waiting we wish to avoid, and/or define busy waiting more particularly? Although we want to match Scratch's project behavior i.e. all execution semantics, we can (and do) differ in the implementation that "gets us there". This may be able to include avoiding certain instances of busy-waiting, but we have to be sure that the definition of busy-waiting is separate from the definition of runtime semantics. The presence or absence of any busy-wait we remove shouldn't affect project semantics.

adroitwhiz commented 1 month ago

Yes, there are still situations where Scratch will busy wait--as you mentioned, an empty "forever" block with nothing to request a redraw will do so, and I believe a "wait" block will do so as well in the absence of any redraw requests.

Scratch will not busy-wait on promises, however--threads have their own "promise wait" state that causes them to be skipped over until the promise resolves. Any thread that is itself waiting for one of those threads to finish will need to do the same--see https://github.com/scratchfoundation/scratch-vm/pull/1211, for instance.

PullJosh commented 1 month ago

While we're discussing promises, this feels like a good time to reference the discussion about triggers: #197 I'm not very happy with the user-facing API for triggers, and it feels like an addEventListener-inspired approach would be better. In that discussion, I created a proof of concept system that includes support for async functions and async generator functions.

adroitwhiz commented 1 month ago

I actually don't think we need async functions or generators--I'm working on an experimental runtime revamp that allows you to simply yield a promise (more or less) and have the runtime automatically pause the script then resume it with the resolved value of the promise.

EDIT: For instance, this is how askAndWait is implemented:

public *askAndWait(question: string): Yielding<void> {
  yield* Thread.await(this._project.askAndWait(question));
}
PullJosh commented 1 month ago

Oh, nice! (I believe yielding a promise is equivalent to an async generator, but I'm not 100% sure.)

adroitwhiz commented 1 month ago

It looks like the difference is that an async generator always yields promises, whereas a regular generator only yields promises if the thing being yielded is already a promise.