Skip to content

Commit

Permalink
[FEAT] TrackableRequests for when async leakage is detected
Browse files Browse the repository at this point in the history
improve waiter erroring

improve trace infra and add tests

only run waiter tests in dev
  • Loading branch information
runspired committed Aug 17, 2018
1 parent db9cf7a commit bc1bdac
Show file tree
Hide file tree
Showing 9 changed files with 581 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export default class BelongsToRelationship extends Relationship {
* true if there is no inverse
* false if the inverse exists and is not loaded (empty)
*
* @returns {boolean}
* @property
* @return {boolean}
*/
get allInverseRecordsAreLoaded() {
let internalModel = this.inverseInternalModel;
Expand Down
3 changes: 2 additions & 1 deletion addon/-legacy-private/system/relationships/state/has-many.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export default class ManyRelationship extends Relationship {
* true if there are no inverse records
* false if the inverse records exist and any are not loaded (any empty)
*
* @returns {boolean}
* @property
* @return {boolean}
*/
get allInverseRecordsAreLoaded() {
// check currentState for unloaded records
Expand Down
90 changes: 86 additions & 4 deletions addon/-legacy-private/system/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,52 @@ Store = Service.extend({
this._serializerCache = Object.create(null);

if (DEBUG) {
this.__asyncRequestCount = 0;
if (this.shouldTrackAsyncRequests === undefined) {
this.shouldTrackAsyncRequests = false;
}
if (this.generateStackTracesForTrackedRequests === undefined) {
this.generateStackTracesForTrackedRequests = false;
}

this._trackedAsyncRequests = [];
this._trackAsyncRequestStart = label => {
let trace =
'set `store.generateStackTracesForTrackedRequests = true;` to get a detailed trace for where this request originated';

if (this.generateStackTracesForTrackedRequests) {
try {
throw new Error(`EmberData TrackedRequest: ${label}`);
} catch (e) {
trace = e;
}
}

let token = Object.freeze({
label,
trace,
});

this._trackedAsyncRequests.push(token);
return token;
};
this._trackAsyncRequestEnd = token => {
let index = this._trackedAsyncRequests.indexOf(token);

if (index === -1) {
throw new Error(
`Attempted to end tracking for the following request but it was not being tracked:\n${token}`
);
}

this._trackedAsyncRequests.splice(index, 1);
};

this.__asyncWaiter = () => {
return this.__asyncRequestCount === 0;
let shouldTrack = this.shouldTrackAsyncRequests;
let tracked = this._trackedAsyncRequests;
let isSettled = tracked.length === 0;

return shouldTrack !== true || isSettled;
};

Ember.Test.registerWaiter(this.__asyncWaiter);
Expand Down Expand Up @@ -833,6 +876,24 @@ Store = Service.extend({
options,
};

if (DEBUG) {
if (this.generateStackTracesForTrackedRequests === true) {
let trace;

try {
throw new Error(`Trace Origin for scheduled fetch for ${modelName}:${id}.`);
} catch (e) {
trace = e;
}

// enable folks to discover the origin of this findRecord call when
// debugging. Ideally we would have a tracked queue for requests with
// labels or local IDs that could be used to merge this trace with
// the trace made available when we detect an async leak
pendingFetchItem.trace = trace;
}
}

let promise = resolver.promise;

internalModel.loadingData(promise);
Expand Down Expand Up @@ -2840,6 +2901,27 @@ Store = Service.extend({

if (DEBUG) {
Ember.Test.unregisterWaiter(this.__asyncWaiter);
let shouldTrack = this.shouldTrackAsyncRequests;
let tracked = this._trackedAsyncRequests;
let isSettled = tracked.length === 0;

if (!isSettled) {
if (shouldTrack) {
throw new Error(
'Async Request leaks detected. Add a breakpoint here and set `store.generateStackTracesForTrackedRequests = true;`to inspect traces for leak origins:\n\t - ' +
tracked.map(o => o.label).join('\n\t - ')
);
} else {
warn(
'Async Request leaks detected. Add a breakpoint here and set `store.generateStackTracesForTrackedRequests = true;`to inspect traces for leak origins:\n\t - ' +
tracked.map(o => o.label).join('\n\t - '),
false,
{
id: 'ds.async.leak.detected',
}
);
}
}
}
},

Expand Down Expand Up @@ -3043,11 +3125,11 @@ function isInverseRelationshipInitialized(store, internalModel, data, key, model
}

/**
*
* @function
* @param store
* @param cache modelFactoryCache
* @param normalizedModelName already normalized modelName
* @returns {*}
* @return {*}
*/
function getModelFactory(store, cache, normalizedModelName) {
let factory = cache[normalizedModelName];
Expand Down
5 changes: 3 additions & 2 deletions addon/-private/system/store/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@ export function _objectIsAlive(object) {
}

export function guardDestroyedStore(promise, store, label) {
let token;
if (DEBUG) {
store.__asyncRequestCount++;
token = store._trackAsyncRequestStart(label);
}
let wrapperPromise = resolve(promise, label).then(v => promise);

return _guard(wrapperPromise, () => {
if (DEBUG) {
store.__asyncRequestCount--;
store._trackAsyncRequestEnd(token);
}
return _objectIsAlive(store);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export default class BelongsToRelationship extends Relationship {
* true if there is no inverse
* false if the inverse exists and is not loaded (empty)
*
* @returns {boolean}
* @return {boolean}
*/
get allInverseRecordsAreLoaded() {
let modelData = this.inverseModelData;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export default class ManyRelationship extends Relationship {
* true if there are no inverse records
* false if the inverse records exist and any are not loaded (any empty)
*
* @returns {boolean}
* @return {boolean}
*/
get allInverseRecordsAreLoaded() {
// check currentState for unloaded records
Expand Down
88 changes: 85 additions & 3 deletions addon/-record-data-private/system/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,52 @@ Store = Service.extend({
this.modelDataWrapper = new ModelDataWrapper(this);

if (DEBUG) {
this.__asyncRequestCount = 0;
if (this.shouldTrackAsyncRequests === undefined) {
this.shouldTrackAsyncRequests = false;
}
if (this.generateStackTracesForTrackedRequests === undefined) {
this.generateStackTracesForTrackedRequests = false;
}

this._trackedAsyncRequests = [];
this._trackAsyncRequestStart = label => {
let trace =
'set `store.generateStackTracesForTrackedRequests = true;` to get a detailed trace for where this request originated';

if (this.generateStackTracesForTrackedRequests) {
try {
throw new Error(`EmberData TrackedRequest: ${label}`);
} catch (e) {
trace = e;
}
}

let token = Object.freeze({
label,
trace,
});

this._trackedAsyncRequests.push(token);
return token;
};
this._trackAsyncRequestEnd = token => {
let index = this._trackedAsyncRequests.indexOf(token);

if (index === -1) {
throw new Error(
`Attempted to end tracking for the following request but it was not being tracked:\n${token}`
);
}

this._trackedAsyncRequests.splice(index, 1);
};

this.__asyncWaiter = () => {
return this.__asyncRequestCount === 0;
let shouldTrack = this.shouldTrackAsyncRequests;
let tracked = this._trackedAsyncRequests;
let isSettled = tracked.length === 0;

return shouldTrack !== true || isSettled;
};

Ember.Test.registerWaiter(this.__asyncWaiter);
Expand Down Expand Up @@ -842,6 +885,24 @@ Store = Service.extend({
options,
};

if (DEBUG) {
if (this.generateStackTracesForTrackedRequests === true) {
let trace;

try {
throw new Error(`Trace Origin for scheduled fetch for ${modelName}:${id}.`);
} catch (e) {
trace = e;
}

// enable folks to discover the origin of this findRecord call when
// debugging. Ideally we would have a tracked queue for requests with
// labels or local IDs that could be used to merge this trace with
// the trace made available when we detect an async leak
pendingFetchItem.trace = trace;
}
}

let promise = resolver.promise;

internalModel.loadingData(promise);
Expand Down Expand Up @@ -3062,6 +3123,27 @@ Store = Service.extend({

if (DEBUG) {
Ember.Test.unregisterWaiter(this.__asyncWaiter);
let shouldTrack = this.shouldTrackAsyncRequests;
let tracked = this._trackedAsyncRequests;
let isSettled = tracked.length === 0;

if (!isSettled) {
if (shouldTrack) {
throw new Error(
'Async Request leaks detected. Add a breakpoint here and set `store.generateStackTracesForTrackedRequests = true;`to inspect traces for leak origins:\n\t - ' +
tracked.map(o => o.label).join('\n\t - ')
);
} else {
warn(
'Async Request leaks detected. Add a breakpoint here and set `store.generateStackTracesForTrackedRequests = true;`to inspect traces for leak origins:\n\t - ' +
tracked.map(o => o.label).join('\n\t - '),
false,
{
id: 'ds.async.leak.detected',
}
);
}
}
}
},

Expand Down Expand Up @@ -3231,7 +3313,7 @@ function _commit(adapter, store, operation, snapshot) {
* @param store
* @param cache modelFactoryCache
* @param normalizedModelName already normalized modelName
* @returns {*}
* @return {*}
*/
function getModelFactory(store, cache, normalizedModelName) {
let factory = cache[normalizedModelName];
Expand Down
54 changes: 40 additions & 14 deletions tests/integration/store-test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import RSVP, { Promise as EmberPromise, resolve } from 'rsvp';
import { Promise, resolve } from 'rsvp';
import { run, next } from '@ember/runloop';
import setupStore from 'dummy/tests/helpers/store';

import Ember from 'ember';
import testInDebug from 'dummy/tests/helpers/test-in-debug';
import deepCopy from 'dummy/tests/helpers/deep-copy';
import { module, test } from 'qunit';
Expand Down Expand Up @@ -76,7 +76,7 @@ test("destroying record during find doesn't cause error", function(assert) {

let TestAdapter = DS.Adapter.extend({
findRecord(store, type, id, snapshot) {
return new EmberPromise((resolve, reject) => {
return new Promise((resolve, reject) => {
next(() => {
store.unloadAll(type.modelName);
reject();
Expand All @@ -93,31 +93,57 @@ test("destroying record during find doesn't cause error", function(assert) {
return run(() => store.findRecord(type, id).then(done, done));
});

test('find calls do not resolve when the store is destroyed', function(assert) {
assert.expect(0);
testInDebug('find calls do not resolve when the store is destroyed', async function(assert) {
assert.expect(2);
let done = assert.async();

let next;
let nextPromise = new Promise(resolve => {
next = resolve;
});
let TestAdapter = DS.Adapter.extend({
findRecord(store, type, id, snapshot) {
store.destroy();
resolve(null);
findRecord() {
next();
nextPromise = new Promise(resolve => {
next = resolve;
}).then(() => {
return {
data: { type: 'car', id: '1' },
};
});
return nextPromise;
},
});

initializeStore(TestAdapter);

let type = 'car';
let id = 1;
// needed for LTS 2.16
Ember.Test.adapter.exception = e => {
throw e;
};

store.shouldTrackAsyncRequests = true;
store.push = function() {
assert('The test should have destroyed the store by now', store.get('isDestroyed'));

throw new Error("We shouldn't be pushing data into the store when it is destroyed");
};
store.findRecord('car', '1');

await nextPromise;

assert.throws(() => {
run(() => store.destroy());
}, /Async Request leaks detected/);

run(() => store.findRecord(type, id));
next();
await nextPromise;

setTimeout(() => done(), 500);
// ensure we allow the internal store promises
// to flush, potentially pushing data into the store
setTimeout(() => {
assert.ok(true, 'We made it to the end');
done();
}, 0);
});

test('destroying the store correctly cleans everything up', function(assert) {
Expand Down Expand Up @@ -228,7 +254,7 @@ test('destroying the store correctly cleans everything up', function(assert) {

function ajaxResponse(value) {
env.adapter.ajax = function(url, verb, hash) {
return run(RSVP, 'resolve', deepCopy(value));
return run(() => resolve(deepCopy(value)));
};
}

Expand Down
Loading

0 comments on commit bc1bdac

Please sign in to comment.