Skip to content
Draft
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
5 changes: 4 additions & 1 deletion bin/run-tests-browser-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ module.exports = class BrowserRunner {
}

async newBrowser() {
let browser = await puppeteer.launch({ dumpio: true });
let browser = await puppeteer.launch({
dumpio: true,
args: ['--js-flags=--expose-gc'],
});
return browser;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/@ember/-internals/owner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,3 +732,5 @@ export interface RegistryProxy extends BasicRegistry {
* APIs which are not exposed on `Owner` itself.
*/
export interface InternalOwner extends RegistryProxy, ContainerProxy {}

export { trackOwner, setupOwnerTracker, type OwnerTrackerCallback } from './leak-detector';
15 changes: 15 additions & 0 deletions packages/@ember/-internals/owner/leak-detector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { InternalOwner } from './index';

export type OwnerTrackerCallback = (owner: InternalOwner) => void;

let ownerTrackerCallback: OwnerTrackerCallback | undefined;

export function setupOwnerTracker(callback: OwnerTrackerCallback) {
ownerTrackerCallback = callback;
}

export function trackOwner(owner: InternalOwner) {
if (ownerTrackerCallback) {
ownerTrackerCallback(owner);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { schedule, join } from '@ember/runloop';
*/
import type Container from '@ember/-internals/container/lib/container';
import Mixin from '@ember/object/mixin';
import type { ContainerProxy } from '@ember/-internals/owner';
import { type ContainerProxy, trackOwner } from '@ember/-internals/owner';

// This is defined as a separate interface so that it can be used in the definition of
// `Owner` without also including the `__container__` property.
Expand All @@ -30,6 +30,12 @@ const ContainerProxyMixin = Mixin.create({
*/
__container__: null,

init() {
this._super();

trackOwner(this);
},

ownerInjection() {
return this.__container__.ownerInjection();
},
Expand Down
1 change: 1 addition & 0 deletions packages/@ember/owner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,5 @@ export {
KnownForTypeResult,
Resolver,
DIRegistry,
setupOwnerTracker as _setupOwnerTracker,
} from '@ember/-internals/owner';
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { setupOwnerTracker, type InternalOwner } from '@ember/-internals/owner';

declare global {
interface Config {
queue: unknown[];
}
}

export function setupOwnerLeakTracker() {
let OWNER_REFS: [WeakRef<InternalOwner>, string][] = [];

setupOwnerTracker((owner: InternalOwner) => {
OWNER_REFS.push([new WeakRef(owner), getTestName()]);
});

function getTestName() {
// the test named `ember-testing Adapters: Adapter is used by default (if
// QUnit is not available)` sets the `Qunit` global to undefined in order
// to confirm the test adapter functions properly without QUnit, this guard
// is specifically to avoid an error in that test case
if (typeof QUnit === 'undefined') {
return '(unknown test)';
}

const currentTest = QUnit.config.current;
return `${currentTest.module.name}: ${currentTest.testName}`;
}

let hasCheckedOwnerLeaks = false;

async function fullyGC() {
if (typeof gc !== 'function') {
throw new Error('Attempting to call `fullyGC` without launching with `--expose-gc`');
}

// Ignoring TS errors below for the `gc` style that we are using,
// modern chromiums accept an argument specifying type of gc (and
// to force it to be promise based)

// @ts-expect-error see above
await gc({ type: 'major', execution: 'async' });
// @ts-expect-error see above
await gc({ type: 'major', execution: 'async' });
// @ts-expect-error see above
await gc({ type: 'major', execution: 'async' });
}

async function checkForRetainedOwners() {
await fullyGC();

const testNameToRetainedOwner = new Map();
for (let [ownerRef, testName] of OWNER_REFS) {
const owner = ownerRef.deref();
if (owner !== undefined) {
let owners = testNameToRetainedOwner.get(testName);
if (owners === undefined) {
owners = [];
testNameToRetainedOwner.set(testName, owners);
}
owners.push(owner);
}
}

OWNER_REFS = [];

return testNameToRetainedOwner;
}

function getOwnerMetadata(owner: any) {
if (owner.mountPoint) {
return `Engine [mounted at \`/${owner.mountPoint}\`]`;
}
return `Application`;
}

// Due to details of how QUnit functions (see https://github.com/qunitjs/qunit/pull/1629)
// we cannot enqueue new tests if we use `runEnd` which is basically what we want (i.e. "when all tests are done run this callback")
//
// Instead we use `suiteEnd` and check `QUnit.config.queue` which indicates the number of tests remaining to be ran
// when `QUnit.config.queue` gets to `0` and `suiteEnd` is running we are finished with all tests **but** `runEnd` / `QUnit.done()` hasn't ran yet so we can still emit new tests
QUnit.moduleDone(() => {
if (QUnit.config.queue.length !== 0) {
return;
}

if (hasCheckedOwnerLeaks) return;
hasCheckedOwnerLeaks = true;

QUnit.module(`[OWNER LEAK DETECTION]`, function () {
const testBody = async function (assert: Assert) {
assert.expect(0);

await fullyGC();

const leakedOwners = await checkForRetainedOwners();
for (const [testName, owners] of leakedOwners) {
for (const owner of owners) {
assert.pushResult({
result: false,
expected: true,
actual: false,

message: `${testName}: Leaked ${getOwnerMetadata(owner)}`,
});
}
}
};
// Opts the user into the `OWNER LEAK DETECTION` module regardless of filter or module selected.
testBody.validTest = true;

QUnit.test('There should be zero leaked Owners', testBody);
});
});
}
7 changes: 7 additions & 0 deletions packages/internal-test-helpers/lib/ember-dev/setup-qunit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { setupObserversCheck } from './observers';
import { setupRunLoopCheck } from './run-loop';
import type { DebugEnv } from './utils';
import { setupWarningHelpers } from './warning';
import { setupOwnerLeakTracker } from './setup-owner-leak-detection';

declare global {
let Ember: any;
Expand Down Expand Up @@ -106,4 +107,10 @@ export default function setupQUnit() {

await QUnit.assert.rejects(promise, expected, message);
};

if (typeof gc === 'undefined') {
// `gc` isn't available, no reason to do **anything** WRT leak detection
return;
}
setupOwnerLeakTracker();
}
6 changes: 6 additions & 0 deletions tsconfig/compiler-options.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
"esModuleInterop": false,
"allowSyntheticDefaultImports": false,

"lib": [
"ES2017",
"ES2021.Weakref",
"DOM",
"DOM.Iterable"
],
"newLine": "LF",

"allowJs": true,
Expand Down