selenium: `async`/`await` breaks the control flow

Meta -

OS: Ubuntu Selenium Version: 2.53.3 or 3.0.0-beta-3 Browser: node Browser Version: v5.10.1 or v4.0.0

Bug

I was trying to improve async/await support for Protractor when I ran into a problem with the folllowing test:

describe('async function + control flow', function() {
  var val;
  var seven;
  it('should wait for this it() to finish', async function() {
    val = 1;
    seven = await webdriver.promise.fulfilled(7);
    controlFlow.execute(() => {
      return webdriver.promise.delayed(1000).then(() => {
        val = seven;
    }));
  });

  it('should have waited for setter in previous it()', function() {
    expect(val).toBe(7); // <<------ This fails
  });
});

async/await are being compiled by the typescript compiler in this case, and jasminewd2 wraps each it() block in the control flow, so this test should have worked. However, the final assertion failed, with val still being 1.

Compiling down to ES6 and stripping out the jasmine/jasminewd2, the above translates to the following:

var webdriver = require('selenium-webdriver'),
    flow = webdriver.promise.controlFlow();

var val;

function runInFlow(fun, name) {
  return flow.execute(() => {
    return webdriver.promise.fulfilled(fun());
  }, name);
}

runInFlow(() => {
  val = 1;
  return new Promise((resolve) => {
    resolve(webdriver.promise.fulfilled(7));
  }).then((seven) => {
    runInFlow(() => {
      return webdriver.promise.delayed(1000).then(() => {
        val = seven;
      });
    }, 'set outer');
  });
}, 'set inner');

runInFlow(() => {
  console.log('RESULT: val = ' + val); // 1, should be 7
}, 'log');

Basically, by putting a webdriver promise inside an ES6 promise inside a webdriver promise, we somehow break the control flow. This is a problem because await compiles down to an ES6 promise, and async functions then return those promises. So if you await some webdriver promise, and then wrap the async function in the control flow, you will run into this bug (as in the first example). This means that Protractor users (or any users who wrap blocks of code in the control flow) basically cannot await an element.getText() command or any other webdriver promise or else everything will become desynchronized.

I know that as per https://github.com/SeleniumHQ/selenium/issues/2969 you plan on removing ManagedPromise/the control flow entirely. But in the mean time, async functions are practically unusable, so this seemed worth bringing to your attention.

About this issue

  • Original URL
  • State: closed
  • Created 8 years ago
  • Reactions: 4
  • Comments: 22 (11 by maintainers)

Most upvoted comments

Well, the good news is I already have the code for disabling the control flow completed, I just haven’t pushed it (hoping this week). So you could tell users if they want to use async, disable the control flow (there’s no need for it if you’re using async)

@jleyba

is there any specific date already for the release 4.0 since this is causing our tests to fail.

FYI 3.0 has been pushed to npm, so you can disable the promise manager now.

The promise manager has been removed from the code base (5650b96185df72ee074f3c660ef5efd227b2739a).

This will be included in the 4.0 release (which won’t go out until Node releases their next LTS later this month).

It’s a timing issue with how the control flow tracks individual turns of the js event loop (which dictates when actions are linked). With your initial example, that first promise just needs to be asynchronously resolved - reproduces with native promises. I really want to sweep this under the rug, but given the control flow isn’t going anywhere for a while, I’ll see if I can track it down.

'use strict';

const assert = require('assert');
const {promise} = require('selenium-webdriver');
const flow = promise.controlFlow();

describe('timing issues', function() {
  function runInFlow(fn) {
    return flow.execute(() => {
      return promise.fulfilled(fn());
    });
  }

  function runTest(seedFn) {
    let value = '';
    return new Promise(resolve => {
      flow.once('idle', resolve);

      runInFlow(() => {
        value += 'a';

        return seedFn().then(() => {
          value += 'b';

          runInFlow(() => {
            value += 'c';
            return promise.delayed(500).then(() => {
              value += 'd';
            });
          });
        })
        .then(_ => value += 'e');
      });

      runInFlow(_ => value += 'f');

    // e before df b/c native promises won't wait for unlinked control flow result.
    }).then(() => assert.equal(value, 'abcedf'));
  }

  function test(seedFn) {
    it(seedFn + '', () => runTest(seedFn));
  }

  test(() => Promise.resolve());
  test(() => Promise.resolve(new Promise(r => r())));
  test(() => new Promise(r => r()));
  test(() => new Promise(r => r(Promise.resolve())));
  test(() => new Promise(r => r(new Promise(r => r()))));
  test(() => new Promise(r => setTimeout(() => r(), 10)));
});
$ mocha promise_test.js 


  timing issues
    ✓ () => Promise.resolve() (513ms)
    ✓ () => Promise.resolve(new Promise(r => r())) (506ms)
    ✓ () => new Promise(r => r()) (507ms)
    1) () => new Promise(r => r(Promise.resolve()))
    2) () => new Promise(r => r(new Promise(r => r())))
    3) () => new Promise(r => setTimeout(() => r(), 10))


  3 passing (3s)
  3 failing

  1) timing issues () => new Promise(r => r(Promise.resolve())):

      AssertionError: 'abcefd' == 'abcedf'
      + expected - actual

      -abcefd
      +abcedf

      at Promise.then (promise_test.js:38:26)

  2) timing issues () => new Promise(r => r(new Promise(r => r()))):

      AssertionError: 'abcefd' == 'abcedf'
      + expected - actual

      -abcefd
      +abcedf

      at Promise.then (promise_test.js:38:26)

  3) timing issues () => new Promise(r => setTimeout(() => r(), 10)):

      AssertionError: 'abcefd' == 'abcedf'
      + expected - actual

      -abcefd
      +abcedf

      at Promise.then (promise_test.js:38:26)

Your example is working as intended. The control flow synchronizes actions within “frames”, which are tied to the JavaScript event loop (as best as the control flow can). Will comment further using your code sample:

runInFlow(() => {
  // Frame 1: this is the initial frame.
  // The control flow will synchronize tasks and callbacks attached to managed
  // promises. The control flow is not able to track native promises.

  val = 1;
  return new Promise((resolve) => {
    resolve(webdriver.promise.fulfilled(7));
  }).then((seven) => {

    // This is a callback on a native promise and runs in its own turn of the JS event loop.
    // Since it is from a native promise, the control flow does not know it was chained from
    // frame 1, so the control flow creates an independent task queue. All tasks and managed
    // promise callbacks within this frame will be synchronized with each other, but not against
    // Frame 1.
    //
    // Return the promise result of this task to make every synchronize.
    runInFlow(() => {
      return webdriver.promise.delayed(1000).then(() => {
        val = seven;
      });
    }, 'set outer');
  });
}, 'set inner');

// This task is scheduled in Frame 1, so it will not execute until "set inner" completes.
runInFlow(() => {
  console.log('RESULT: val = ' + val); // 1, should be 7
}, 'log');

Same with your first example, add an await/return to link everything up:

describe('async function + control flow', function() {
  var val;
  var seven;
  it('should wait for this it() to finish', async function() {
    val = 1;
    seven = await webdriver.promise.fulfilled(7);
    await controlFlow.execute(() => {
      return webdriver.promise.delayed(1000).then(() => {
        val = seven;
    }));
  });

  it('should have waited for setter in previous it()', function() {
    expect(val).toBe(7); // <<------ This fails
  });
});