Skip to content

Fix race condition in animation resolution #1108

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Nov 10, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/plot_api/plot_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2214,6 +2214,20 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
}
}

// Execute a callback after the wrapper function has been called n times.
// This is used to defer the resolution until a transition has resovled *and*
// the frame has completed. If it's not done this way, then we get a race
// condition in which the animation might resolve before a transition is complete
// or vice versa.
function callbackOnNthTime(cb, n) {
var cnt = 0;
return function() {
if(cb && ++cnt === n) {
return cb();
}
};
}

return new Promise(function(resolve, reject) {
function discardExistingFrames() {
if(trans._frameQueue.length === 0) {
Expand Down Expand Up @@ -2264,7 +2278,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
// loop (which may exist as a result of a *different* .animate call)
// still resolves or rejecdts this .animate call's promise. once it's
// complete.
nextFrame.onComplete = resolve;
nextFrame.onComplete = callbackOnNthTime(resolve, 2);
Copy link
Contributor Author

@rreusser rreusser Nov 9, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this must now be executed in two places before the transition is registered as having completed. That prevents it from resolving before the transition is actually complete. The transition duration is clipped to no greater than the frame duration, but this PR exists because that still didn't ensure that the promise resolved after both had completed, which made ordering difficult to rely upon.

nextFrame.onInterrupt = reject;
}

Expand Down Expand Up @@ -2302,7 +2316,6 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
// Execute the callback and unset it to ensure it doesn't
// accidentally get called twice
trans._currentFrame.onComplete();
trans._currentFrame.onComplete = null;
}

var newFrame = trans._currentFrame = trans._frameQueue.shift();
Expand All @@ -2322,7 +2335,12 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
helpers.coerceTraceIndices(gd, newFrame.frame.traces),
newFrame.frameOpts,
newFrame.transitionOpts
);
).then(function() {
if(newFrame.onComplete) {
newFrame.onComplete();
}

});

gd.emit('plotly_animatingframe', {
name: newFrame.name,
Expand Down
25 changes: 19 additions & 6 deletions src/plots/plots.js
Original file line number Diff line number Diff line change
Expand Up @@ -1104,8 +1104,16 @@ plots.purge = function(gd) {
// remove modebar
if(fullLayout._modeBar) fullLayout._modeBar.destroy();

if(gd._transitionData && gd._transitionData._animationRaf) {
window.cancelAnimationFrame(gd._transitionData._animationRaf);
if(gd._transitionData) {
// Ensure any dangling callbacks are simply dropped if the plot is purged.
// This is more or less only actually important for testing.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loving the honesty here 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aiming for useful comments. 😄

coding

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testing hammers this use-case in every conceivable way and only fails once in a rare while, as far as I'm aware only when karma interrupts and restarts the tests. When that happens, it goes kinda haywire and you just have to restart karma. In actual code though, it's so rare that I'd be maybe a little surprised if anyone ever encountered it.

if(gd._transitionData._interruptCallbacks) {
gd._transitionData._interruptCallbacks.length = 0;
}

if(gd._transitionData._animationRaf) {
window.cancelAnimationFrame(gd._transitionData._animationRaf);
}
}

// data and layout
Expand Down Expand Up @@ -1714,6 +1722,9 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts)
var aborted = false;

function executeTransitions() {

gd.emit('plotly_transitioning', []);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subtle, but I didn't like that this was executed asynchronously with the transition actually starting, hence the move.


return new Promise(function(resolve) {
// This flag is used to disabled things like autorange:
gd._transitioning = true;
Expand Down Expand Up @@ -1798,7 +1809,9 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts)
}

function completeTransition(callback) {
// Fail-safe against purged plot:
// This a simple workaround for tests which purge the graph before animations
// have completed. That's not a very common case, so this is the simplest
// fix.
if(!gd._transitionData) return;

flushCallbacks(gd._transitionData._interruptCallbacks);
Expand Down Expand Up @@ -1848,13 +1861,13 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts)

var seq = [plots.previousPromises, interruptPreviousTransitions, prepareTransitions, executeTransitions];


var transitionStarting = Lib.syncOrAsync(seq, gd);

if(!transitionStarting || !transitionStarting.then) transitionStarting = Promise.resolve();
if(!transitionStarting || !transitionStarting.then) {
transitionStarting = Promise.resolve();
}

return transitionStarting.then(function() {
gd.emit('plotly_transitioning', []);
return gd;
});
};
Expand Down
87 changes: 75 additions & 12 deletions test/jasmine/tests/animate_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -591,32 +591,84 @@ describe('Test animate API', function() {
describe('Animate API details', function() {
'use strict';

var gd, mockCopy;
var gd;
var dur = 30;
var mockCopy;

beforeEach(function(done) {
gd = createGraphDiv();

mockCopy = Lib.extendDeep({}, mock);

Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() {
return Plotly.addFrames(gd, mockCopy.frames);
}).then(done);
Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done);
});

afterEach(function() {
Plotly.purge(gd);
destroyGraphDiv();
});

it('null frames should not break everything', function(done) {
gd._transitionData._frames.push(null);
it('redraws after a layout animation', function(done) {
var redraws = 0;
gd.on('plotly_redraw', function() {redraws++;});

Plotly.animate(gd, null, {
frame: {duration: 0},
transition: {duration: 0}
Plotly.animate(gd,
{layout: {'xaxis.range': [0, 1]}},
{frame: {redraw: true, duration: dur}, transition: {duration: dur}}
).then(function() {
expect(redraws).toBe(1);
Copy link
Contributor

@etpinard etpinard Nov 10, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, if I get this right, before this PR, redraws === 2 here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite, but similar. Sometimes it was zero because the promise resolved before both the frame and the transition were registered as complete. Now the promise doesn't resolve until both are done—which shouldn't be more than a couple ms difference, but that's enough to mess up the ordering.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep that makes sense. Thanks for the info!

}).catch(fail).then(done);
});

it('forces a relayout after layout animations', function(done) {
var relayouts = 0;
var restyles = 0;
var redraws = 0;
gd.on('plotly_relayout', function() {relayouts++;});
gd.on('plotly_restyle', function() {restyles++;});
gd.on('plotly_redraw', function() {redraws++;});

Plotly.animate(gd,
{layout: {'xaxis.range': [0, 1]}},
{frame: {redraw: false, duration: dur}, transition: {duration: dur}}
).then(function() {
expect(relayouts).toBe(1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice test 🎉

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops. I removed the requestAnimationFrame around this, so the comment is now incorrect. 😭

Copy link
Contributor Author

@rreusser rreusser Nov 8, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@etpinard removed this comment and added a couple other very minor animation tweaks that drop callbacks if the plot is purged before they're executed—which turns out to be a great way to break the tests one out of every hundred times 😬

(The extra fix was mainly added here to get it to run circle-ci again without the failing enterprise thing)

expect(restyles).toBe(0);
expect(redraws).toBe(0);
}).catch(fail).then(done);
});

it('triggers plotly_animated after a single layout animation', function(done) {
var animateds = 0;
gd.on('plotly_animated', function() {animateds++;});

Plotly.animate(gd, [
{layout: {'xaxis.range': [0, 1]}},
], {frame: {redraw: false, duration: dur}, transition: {duration: dur}}
).then(function() {
// Wait a bit just to be sure:
setTimeout(function() {
expect(animateds).toBe(1);
done();
}, dur);
});
});

it('triggers plotly_animated after a multi-step layout animation', function(done) {
var animateds = 0;
gd.on('plotly_animated', function() {animateds++;});

Plotly.animate(gd, [
{layout: {'xaxis.range': [0, 1]}},
{layout: {'xaxis.range': [2, 4]}},
], {frame: {redraw: false, duration: dur}, transition: {duration: dur}}
).then(function() {
// Wait a bit just to be sure:
setTimeout(function() {
expect(animateds).toBe(1);
done();
}, dur);
});
});

it('does not fail if strings are not used', function(done) {
Plotly.addFrames(gd, [{name: 8, data: [{x: [8, 7, 6]}]}]).then(function() {
// Verify it was added as a string name:
Expand All @@ -634,12 +686,23 @@ describe('Animate API details', function() {
var cnt = 0;
gd.on('plotly_animatingframe', function() {cnt++;});

Plotly.animate(gd, ['frame0', null, undefined], {transition: {duration: 0}, frame: {duration: 0}}).then(function() {
Plotly.addFrames(gd, mockCopy.frames).then(function() {
return Plotly.animate(gd, ['frame0', null, undefined], {transition: {duration: 0}, frame: {duration: 0}});
}).then(function() {
// Check only one animating was fired:
expect(cnt).toEqual(1);

// Check unused frames did not affect the current frame:
expect(gd._fullLayout._currentFrame).toEqual('frame0');
}).catch(fail).then(done);
});

it('null frames should not break everything', function(done) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is from a manually merged PR, I believe.

gd._transitionData._frames.push(null);

Plotly.animate(gd, null, {
frame: {duration: 0},
transition: {duration: 0}
}).catch(fail).then(done);
});
});