Skip to content

Recursive custom blocks don't automatically yield #198

Open
@towerofnix

Description

@towerofnix

Super simple demo project: Recursive diamond animation!

Up to five "stack layers" deep, Scratch considers the possibility of a recursive procedure call, — that is, a custom block that's been behaviorally nested inside of its own definition, directly or indirectly! — and if so, it performs a yield, which may for example wait for a screen refresh.

The sneaky thing is in the definition of "stack layer". In Scratch, a stack layer is any level of evaluation nesting. It's sort of like the JavaScript call stack, but because everything in Scratch is a block, even basic control structures push a new stack layer.

Imagine something like this:

*myCoolScript() {
  this.callStack.push({why: 'myCoolScript'});
  if ('foo' !== 'bar') {
    this.callStack.push({why: 'if'});
    yield* this.sayAndWait('That makes sense!', 2);
    yield* this.doSomethingElse();
    this.callStack.pop();
  } else {
    this.callStack.push({why: 'if-else'});
    yield* this.thinkAndWait('Oh dear, oh dear', 3);
    while (this.fruits.length > 0) {
      this.callStack.push({why: 'while'});
      yield* this.eatFruit(this.fruits[Math.floor(Math.random() * this.fruits.length)]);
      this.callStack.pop();
    }
    this.callStack.pop();
  }
  this.callStack.pop();
}

*doSomethingElse() {
  this.callStack.push({why: 'doSomethingElse'});
  yield* this.myCoolScript();
  this.callStack.pop();
}

*eatFruit(fruit) {
  this.callStack.push({why: 'eatFruit'});
  if (Math.random() > 0.25) {
    this.callStack.push({why: 'if'});
    if (Math.random() > 0.25 /* no really */) {
      this.callStack.push({why: 'if'});
      yield* this.myCoolScript();
      this.callStack.pop();
    }
    this.callStack.pop();
  }
  this.callStack.pop();
}

Now this is terrible to read, but don't let that scare you away just yet! It's just to explain what Scratch is seeing. We're not Scratch, so we can make a slightly nicer abstraction out of this, but first we have to understand what Scratch is doing.

Let's imagine we follow the path into this.doSomethingElse() because 'foo' does in fact !== 'bar'. The actual execution order, which the JavaScript or Scratch runtime would take, looks like this:

  1. this.callStack.push({why: 'myCoolScript'}); » Stack: [myCoolScript]
  2. this.callStack.push({why: 'if'}); » Stack: [myCoolScript, if]
  3. yield* this.sayAndWait('That makes sense!', 2); » output
  4. yield* this.doSomethingElse(); » enter custom block!
  5. this.callStack.push({why: 'doSomethingElse'}); » Stack: [myCoolScript, if, doSomethingElse]
  6. yield* this.myCoolScript(); » enter custom block!
  7. this.callStack.push({why: 'myCoolScript'}); » Stack: [myCoolScript, if, doSomethingElse, myCoolScript]
  8. ...and on and on, infinitely, since this will just recurse forever.

So, right before Scratch begins executing the contents of any custom block (steps 4 and 6 above), it checks out the top five layers of the stack list. If any of those layers the custom block which it's in the process of starting, Scratch inserts a yield (possibly waiting for a screen refresh, etc).

At step 4, it's entering doSomethingElse. The stack is [myCoolScript, if]. The top five layers of that stack are also just [myCoolScript, if]. That doesn't include doSomethingElse, so Scratch doesn't insert a yield here. (It just begins executing the script's contents immediately.)

At step 6, it's entering myCoolScript. The stack is [myCoolScript, if, doSomethingElse]. The top five layers of that stack are also just [myCoolScript, if, doSomethingElse], and of course, that includes myCoolScript... so it inserts a yield, here.

OK, let's say the sky is falling!! Now 'foo' === 'bar'! What happens this time!?

  1. this.callStack.push({why: 'myCoolScript'}); » Stack: [myCoolScript]
  2. this.callStack.push({why: 'if-else'}); » Stack: [myCoolScript, if-else]
  3. yield* this.thinkAndWait('Oh dear, oh dear', 3); » output
  4. this.callStack.push({why: 'while'}); » Stack: [myCoolScript, if-else, while] (assuming we have any fruit, of course ✨)
  5. yield* this.eatFruit(this.fruits[...]); » enter custom block!
  6. this.callStack.push({why: 'eatFruit'}); » Stack: [myCoolScript, if-else, while, eatFruit]
  7. this.callStack.push({why: 'if'}); » Stack: [myCoolScript, if-else, while, eatFruit, if] (assuming we're lucky)
  8. this.callStack.push({why: 'if'}); » Stack: [myCoolScript, if-else, while, eatFruit, if, if] (assuming we're lucky again)
  9. yield* this.myCoolScript(); » enter custom block!
  10. this.callStack.push({why: 'myCoolScript'}); » Stack: [myCoolScript, if-else, while, eatFruit, if, if, myCoolScript]
  11. ...and on and on, infinitely, since this will just recurse forever. Or until we're out of fruit!

The same recursion rule applies, of course, during steps 5 and 9 here.

Step 5 is just like before - no eatFruit on the stack, so no yield.

But step 9 is more interesting. The stack is [myCoolScript, if-else, while, eatFruit, if, if]. The top five layers of this, though, are only [if-else, while, eatFruit, if, if]! Even though we're recursing into myCoolScript, which is on the stack, it's too far away from the top, and Scratch never sees it. So... it doesn't insert a yield here!

See how that only happens because of how far we were nested inside ifs and if-elses and other custom blocks? Those simple control strucures really made a difference—one that could have a real impact, depending how badly a project turns out to depend on Scratch's behavior.


OK, we'll reply to this with a couple syntactical options we've considered, but here's just the summary of Scratch's behavior, to start!

Metadata

Metadata

Assignees

No one assigned

    Labels

    compatibilityBugs that cause Leopard's behavior to differ from Scratch'sdiscussionLooking for feedback and input

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions