mirror of
https://github.com/electron/electron.git
synced 2026-10-28 11:44:46 +08:00
* test: use precise GC and live menu counts in the menu rebuild spec
The spec measured the whole C++ heap after collecting with
v8Util.requestGarbageCollectionForTesting(), a synchronous GC that scans
the native stack conservatively. A replaced menu, its submenus and its
items all reference each other, so a stale pointer to any one of them
keeps the whole menu alive: about 75 KB, several times the spec's 10%
allowance. In 100 local Linux runs, that GC kept one or two replaced
menus alive in 3 runs, while precise GC kept none. A single Windows
failure grew by 75,680 bytes, close to the size of one menu.
Collect with gc({ type: 'major', execution: 'async' }), which does not
scan the stack, and check what the regression is about: every replaced
menu's WeakRef is cleared, the live Menu and MenuItem counts are equal
after identical rounds, and the current application menu stays alive.
The whole-heap growth check stays as a secondary check with the same
allowance; with precise GC the heap size was identical across rounds.
Only synchronous helpers hold the menus: a suspended async function
keeps its registers, and a menu left in one stayed alive after being
replaced. The failure message also said 100 rebuilds per round instead
of 50.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: use precise GC in the other cpp-heap growth specs
The download, chunked upload and utilityProcess leak specs measured the
whole C++ heap after the synchronous v8Util GC, which scans the native
stack conservatively. The check also could not see a leak of the
objects these specs are about. In local runs, keeping every
DownloadItem, every request with its URL loader, or every exited child
alive stayed within the 10% allowance and the old specs passed; only
retained UtilityProcess wrappers went over it, and only just.
Collect with gc({ type: 'major', execution: 'async' }) and check those
objects: a WeakRef to every download item, request and child is cleared
after each round, and no DownloadItem, SimpleURLLoaderWrapper,
JSChunkedDataPipeGetter or UtilityProcess wrapper is left alive. A
count taken while an object is still in use must see it, so a renamed
type cannot pass the zero check. The whole-heap check stays as a
secondary check with the same allowance; it did not change between
rounds.
The per-operation helpers are synchronous and resolve with numbers, so
no suspended async frame holds a download item, request or child.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: share the cpp-heap object-count and precise-GC helpers
The menu, download, chunked upload, utilityProcess and PromiseHandle
specs each carried a copy of the code that counts C++ heap objects by
cppgc type name and of the precise collection with
gc({ type: 'major', execution: 'async' }). Move both into
spec/lib/cpp-heap-helpers.js as countCppHeapObjects() and
collectGarbagePrecisely(), which the remotely() bodies require the way
they require heapsnapshot-helpers.js. The specs' assertions are
unchanged. The menu spec now reads the heap size from brief statistics,
like the others.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: address review notes on the cpp-heap growth specs
On macOS, Menu.setApplicationMenu() installs the menu on a later
run-loop pass, and until then the previously installed menu stays
alive. If that pass had not run before the last collection, the menu
spec would see one replaced menu still alive. Yield one macrotask
before collecting.
The utilityProcess spec takes its in-use count right after fork(),
before the child runs, so say that, and fail if a child exits with a
nonzero code, so the spec cannot pass when every launch fails. The
comments on the whole-heap checks now call them what they are: a
coarse secondary check for growth in other types.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: simplify the cpp-heap growth specs
The downloads, uploads, menu and utilityProcess growth specs each
counted their uncollected WeakRefs with the same filter. Export it from
cpp-heap-helpers.js as countAlive(), the name the menu spec gave its
local copy.
Once the helper took over the counting, the menu spec's measure() was a
one-use wrapper around an object literal, and its name meant something
different from measure() in the other specs. Return the literal from
rebuildAndMeasure() instead. The Menu and MenuItem counts now sit under
`live`, as in the uploads spec, so one toEqual() compares both across
rounds; it fails whenever either of the two toBe() checks would have.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Revert "test: use precise GC and live menu counts in the menu rebuild spec"
This reverts commit 116ecfe671.
* test: extract synchronous rebuild helper in menu heap spec
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: deepak1556 <hop2deep@gmail.com>
3079 lines
127 KiB
TypeScript
3079 lines
127 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { once } from 'node:events';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
|
|
import {
|
|
defer,
|
|
ifdescribe,
|
|
ifit,
|
|
isTestingBindingAvailable,
|
|
itremote,
|
|
startRemoteControlApp
|
|
} from './lib/spec-helpers.ts';
|
|
|
|
const fixturesPath = path.join(import.meta.dirname, 'fixtures');
|
|
|
|
describe('cpp heap', () => {
|
|
ifdescribe(isTestingBindingAvailable())('native callback holders', () => {
|
|
for (const [mode, description] of [
|
|
['call', 'creates callable images through an API retained from a removed iframe'],
|
|
['growth', 'does not retain templates linearly when creating images in a detached realm'],
|
|
['release', 'collects a detached realm and its callback holder after releasing the API and images']
|
|
]) {
|
|
it(description, { timeout: 90_000 }, async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
await remotely(
|
|
async (fixture: string, page: string, child: string, snapshotHelper: string, mode: string) => {
|
|
await require(fixture)(page, child, snapshotHelper, mode);
|
|
},
|
|
path.join(fixturesPath, 'api', 'cppgc-detached-frame.js'),
|
|
path.join(fixturesPath, 'pages', 'blank.html'),
|
|
path.join(fixturesPath, 'api', 'cppgc-detached-frame.html'),
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js'),
|
|
mode
|
|
);
|
|
});
|
|
}
|
|
|
|
it('does not retain contexts through cached templates after reload', { timeout: 60_000 }, async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (page: string, snapshotHelper: string) => {
|
|
const { app, BrowserWindow } = require('electron');
|
|
app.on('window-all-closed', () => {});
|
|
const { once } = require('node:events');
|
|
const { mkdtemp, readFile, rm, unlink } = require('node:fs/promises');
|
|
const { tmpdir } = require('node:os');
|
|
const { join } = require('node:path');
|
|
const { countHeapSnapshotNodes } = require(snapshotHelper);
|
|
const snapshotDir = await mkdtemp(join(tmpdir(), 'electron-cache-snapshot-'));
|
|
const snapshotPath = join(snapshotDir, 'renderer.heapsnapshot');
|
|
const window = new BrowserWindow({
|
|
show: false,
|
|
webPreferences: {
|
|
nodeIntegration: true,
|
|
contextIsolation: false,
|
|
sandbox: false
|
|
}
|
|
});
|
|
const countMarkers = async () => {
|
|
await window.webContents.executeJavaScript(`
|
|
(async () => {
|
|
const v8Util = process._linkedBinding('electron_common_v8_util');
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
})()
|
|
`);
|
|
// Collect outside renderer JS to avoid reentrant Node stream
|
|
// callbacks while Chromium is serializing the renderer heap.
|
|
await window.webContents.takeHeapSnapshot(snapshotPath);
|
|
try {
|
|
return countHeapSnapshotNodes(await readFile(snapshotPath), {
|
|
markers: { name: 'PerContextCacheSentinel', type: 'object' }
|
|
}).markers;
|
|
} finally {
|
|
await unlink(snapshotPath);
|
|
}
|
|
};
|
|
|
|
try {
|
|
await window.loadFile(page);
|
|
const rendererPid = window.webContents.getOSProcessId();
|
|
const counts: number[] = [];
|
|
const rendererPids: number[] = [];
|
|
let liveCount = 0;
|
|
for (let i = 0; i < 3; i++) {
|
|
await window.webContents.executeJavaScript(`
|
|
(() => {
|
|
const { nativeImage } = require('electron');
|
|
class PerContextCacheSentinel {}
|
|
globalThis.cacheSentinel = new PerContextCacheSentinel();
|
|
globalThis.cacheSentinel.image = nativeImage.createEmpty();
|
|
if (!globalThis.cacheSentinel.image.isEmpty()) throw new Error('unexpected image');
|
|
})()
|
|
`);
|
|
if (i === 0) {
|
|
liveCount = await countMarkers();
|
|
}
|
|
|
|
// Reload discards the context but not the isolate: an
|
|
// isolate wide cache must not keep the old global alive.
|
|
const loaded = once(window.webContents, 'did-finish-load', {
|
|
signal: AbortSignal.timeout(20_000)
|
|
});
|
|
window.webContents.reload();
|
|
await loaded;
|
|
rendererPids.push(window.webContents.getOSProcessId());
|
|
counts.push(await countMarkers());
|
|
}
|
|
return { rendererPid, rendererPids, liveCount, counts };
|
|
} finally {
|
|
window.destroy();
|
|
await rm(snapshotDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
path.join(fixturesPath, 'pages', 'blank.html'),
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result.liveCount, 'the live context must retain its marker').toBe(1);
|
|
expect(result.rendererPid).toBeGreaterThan(0);
|
|
expect(result.rendererPids).toEqual(Array(3).fill(result.rendererPid));
|
|
expect(result.counts, 'discarded contexts must release their markers').toEqual([0, 0, 0]);
|
|
});
|
|
|
|
it('caches function templates per isolate on the main thread and skips the cache in workers', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (fixture: string) => {
|
|
const { once } = require('node:events');
|
|
const { Worker } = require('node:worker_threads');
|
|
const testing = process._linkedBinding('electron_common_testing');
|
|
const first = testing.getCachedCallbackTemplateForTesting();
|
|
const cachedOnMain = first === testing.getCachedCallbackTemplateForTesting() && first() === 42;
|
|
const worker = new Worker(fixture);
|
|
try {
|
|
const signal = AbortSignal.timeout(20_000);
|
|
const [[message], [code]] = await Promise.all([
|
|
once(worker, 'message', { signal }),
|
|
once(worker, 'exit', { signal })
|
|
]);
|
|
return { cachedOnMain, message, code };
|
|
} finally {
|
|
await worker.terminate();
|
|
}
|
|
},
|
|
path.join(fixturesPath, 'api', 'cppgc-template-cache-worker.js')
|
|
);
|
|
expect(result).toEqual({ cachedOnMain: true, message: 'done', code: 0 });
|
|
});
|
|
|
|
for (const mode of ['gc', 'exit', 'terminate']) {
|
|
it(`destroys callback holders on worker ${mode}`, async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (fixture: string, mode: string) => {
|
|
const { once } = require('node:events') as typeof import('node:events');
|
|
const { Worker } = require('node:worker_threads');
|
|
const testing = process._linkedBinding('electron_common_testing');
|
|
const before = testing.getLiveCallbackHolderProbeCountForTesting();
|
|
const worker = new Worker(fixture);
|
|
const signal = AbortSignal.timeout(20_000);
|
|
// Register both listeners before the worker can send or exit.
|
|
const ready = once(worker, 'message', { signal });
|
|
const exited = once(worker, 'exit', { signal });
|
|
try {
|
|
const [state, [code]] = await Promise.all([
|
|
ready.then(async ([message]) => {
|
|
const during = testing.getLiveCallbackHolderProbeCountForTesting();
|
|
if (mode === 'terminate') {
|
|
await worker.terminate();
|
|
} else {
|
|
worker.postMessage(mode);
|
|
}
|
|
return { message, during };
|
|
}),
|
|
exited
|
|
]);
|
|
return {
|
|
...state,
|
|
code,
|
|
before,
|
|
after: testing.getLiveCallbackHolderProbeCountForTesting()
|
|
};
|
|
} finally {
|
|
await worker.terminate();
|
|
}
|
|
},
|
|
path.join(fixturesPath, 'api', 'cppgc-callback-lifetime-worker.js'),
|
|
mode
|
|
);
|
|
expect(result.message).toBe('ready');
|
|
expect(result.during).toBe(result.before + 1);
|
|
expect(result.after, 'worker teardown must destroy native callback state').toBe(result.before);
|
|
expect(result.code).toBe(mode === 'terminate' ? 1 : 0);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('app module', () => {
|
|
it('should not allocate on every require', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const [usedBefore, usedAfter] = await remotely(async () => {
|
|
const { getCppHeapStatistics } = require('node:v8');
|
|
const heapStatsBefore = getCppHeapStatistics('brief');
|
|
{
|
|
const { app } = require('electron');
|
|
console.log(app.name);
|
|
}
|
|
{
|
|
const { app } = require('electron');
|
|
console.log(app.dock);
|
|
}
|
|
const heapStatsAfter = getCppHeapStatistics('brief');
|
|
return [heapStatsBefore.used_size_bytes, heapStatsAfter.used_size_bytes];
|
|
});
|
|
expect(usedBefore).toBe(usedAfter);
|
|
});
|
|
|
|
it('should record as node in heap snapshot', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const snapshot = takeHeapSnapshot();
|
|
return snapshotContainsRetainingPath(snapshot, ['C++ Persistent roots', 'Electron / App']);
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('nativeTheme module', () => {
|
|
it('should not allocate on every require', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const [usedBefore, usedAfter] = await remotely(async () => {
|
|
const { nativeTheme } = require('electron');
|
|
const { getCppHeapStatistics } = require('node:v8');
|
|
console.log(nativeTheme.shouldUseDarkColors);
|
|
const heapStatsBefore = getCppHeapStatistics('brief');
|
|
{
|
|
const { nativeTheme } = require('electron');
|
|
console.log(nativeTheme.themeSource);
|
|
}
|
|
{
|
|
const { nativeTheme } = require('electron');
|
|
console.log(nativeTheme.shouldUseHighContrastColors);
|
|
}
|
|
const heapStatsAfter = getCppHeapStatistics('brief');
|
|
return [heapStatsBefore.used_size_bytes, heapStatsAfter.used_size_bytes];
|
|
});
|
|
expect(usedBefore).toBe(usedAfter);
|
|
});
|
|
|
|
it('should record as node in heap snapshot', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { nativeTheme } = require('electron');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
console.log(nativeTheme.shouldUseDarkColors);
|
|
return snapshotContainsRetainingPath(takeHeapSnapshot(), ['C++ Persistent roots', 'Electron / NativeTheme']);
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
ifdescribe(process.platform === 'darwin')('pushNotifications module', () => {
|
|
it('should not allocate on every require', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const [usedBefore, usedAfter] = await remotely(async () => {
|
|
const { pushNotifications } = require('electron');
|
|
const { getCppHeapStatistics } = require('node:v8');
|
|
console.log(typeof pushNotifications.registerForAPNSNotifications);
|
|
const heapStatsBefore = getCppHeapStatistics('brief');
|
|
{
|
|
const { pushNotifications } = require('electron');
|
|
console.log(typeof pushNotifications.unregisterForAPNSNotifications);
|
|
}
|
|
{
|
|
const { pushNotifications } = require('electron');
|
|
console.log(pushNotifications.eventNames());
|
|
}
|
|
const heapStatsAfter = getCppHeapStatistics('brief');
|
|
return [heapStatsBefore.used_size_bytes, heapStatsAfter.used_size_bytes];
|
|
});
|
|
expect(usedBefore).toBe(usedAfter);
|
|
});
|
|
|
|
it('should record as node in heap snapshot', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { pushNotifications } = require('electron');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
console.log(typeof pushNotifications.registerForAPNSNotifications);
|
|
return snapshotContainsRetainingPath(takeHeapSnapshot(), [
|
|
'C++ Persistent roots',
|
|
'Electron / PushNotifications'
|
|
]);
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('safeStorage module', () => {
|
|
it('should not allocate on every require', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const [usedBefore, usedAfter] = await remotely(async () => {
|
|
const { safeStorage } = require('electron');
|
|
const { getCppHeapStatistics } = require('node:v8');
|
|
console.log(typeof safeStorage.isAsyncEncryptionAvailable);
|
|
const heapStatsBefore = getCppHeapStatistics('brief');
|
|
{
|
|
const { safeStorage } = require('electron');
|
|
console.log(typeof safeStorage.encryptStringAsync);
|
|
}
|
|
{
|
|
const { safeStorage } = require('electron');
|
|
console.log(typeof safeStorage.decryptStringAsync);
|
|
}
|
|
const heapStatsAfter = getCppHeapStatistics('brief');
|
|
return [heapStatsBefore.used_size_bytes, heapStatsAfter.used_size_bytes];
|
|
});
|
|
expect(usedBefore).toBe(usedAfter);
|
|
});
|
|
|
|
it('should record as node in heap snapshot', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { safeStorage } = require('electron');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
console.log(typeof safeStorage.isAsyncEncryptionAvailable);
|
|
return snapshotContainsRetainingPath(takeHeapSnapshot(), ['C++ Persistent roots', 'Electron / SafeStorage']);
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('systemPreferences module', () => {
|
|
it('does not crash on exit with a live wrapper', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
const exited = once(rc.process, 'exit');
|
|
await rc.remotely(async () => {
|
|
const { app, systemPreferences } = require('electron');
|
|
systemPreferences.getAnimationSettings();
|
|
if (process.platform === 'darwin') {
|
|
systemPreferences.subscribeLocalNotification(null, () => {});
|
|
}
|
|
setTimeout(() => app.quit());
|
|
});
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
it('should not allocate on every require', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const [usedBefore, usedAfter] = await remotely(async () => {
|
|
const { systemPreferences } = require('electron');
|
|
const { getCppHeapStatistics } = require('node:v8');
|
|
console.log(typeof systemPreferences.getAnimationSettings);
|
|
const heapStatsBefore = getCppHeapStatistics('brief');
|
|
{
|
|
const { systemPreferences } = require('electron');
|
|
console.log(typeof systemPreferences.getAccentColor);
|
|
}
|
|
{
|
|
const { systemPreferences } = require('electron');
|
|
console.log(systemPreferences.eventNames());
|
|
}
|
|
const heapStatsAfter = getCppHeapStatistics('brief');
|
|
return [heapStatsBefore.used_size_bytes, heapStatsAfter.used_size_bytes];
|
|
});
|
|
expect(usedBefore).toBe(usedAfter);
|
|
});
|
|
|
|
it('should record as node in heap snapshot', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { systemPreferences } = require('electron');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
console.log(typeof systemPreferences.getAnimationSettings);
|
|
return snapshotContainsRetainingPath(takeHeapSnapshot(), [
|
|
'C++ Persistent roots',
|
|
'Electron / SystemPreferences'
|
|
]);
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('session module', () => {
|
|
it('does not crash on exit with live session wrappers', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
const exited = once(rc.process, 'exit');
|
|
await rc.remotely(async () => {
|
|
const { app, session } = require('electron');
|
|
|
|
const sessions = [
|
|
session.defaultSession,
|
|
session.fromPartition('cppheap-exit'),
|
|
session.fromPartition('persist:cppheap-exit-persist')
|
|
];
|
|
|
|
// We want to test GC on shutdown, so add a global reference
|
|
// to these sessions to prevent pre-shutdown GC.
|
|
(globalThis as any).sessionRefs = sessions;
|
|
|
|
// We want to test CppGC-traced references during shutdown.
|
|
// The CppGC-managed cookies will do that; but since they're
|
|
// lazy-created, access them here to ensure they're live.
|
|
sessions.forEach((ses) => ses.cookies);
|
|
|
|
setTimeout(() => app.quit());
|
|
});
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
it('should record as node in heap snapshot', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { session, BrowserWindow } = require('electron');
|
|
const { once } = require('node:events');
|
|
const assert = require('node:assert');
|
|
const { countRetainingPathNodes, snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const session1 = session.defaultSession;
|
|
console.log(session1.getStoragePath());
|
|
const session2 = session.fromPartition('cppheap1');
|
|
const session3 = session.fromPartition('cppheap1');
|
|
const session4 = session.fromPartition('cppheap2');
|
|
console.log(session2.cookies);
|
|
assert.strictEqual(session2, session3);
|
|
assert.notStrictEqual(session2, session4);
|
|
const w = new BrowserWindow({
|
|
show: false,
|
|
webPreferences: {
|
|
session: session.fromPartition('cppheap1')
|
|
}
|
|
});
|
|
await w.loadURL('about:blank');
|
|
const snapshot = takeHeapSnapshot();
|
|
const isClosed = once(w, 'closed');
|
|
w.destroy();
|
|
await isClosed;
|
|
const hasThreeSessionRoots =
|
|
countRetainingPathNodes(snapshot, ['C++ Persistent roots', 'Electron / Session']) === 3;
|
|
const tracesWindowSession = snapshotContainsRetainingPath(snapshot, [
|
|
'Electron / WebContents',
|
|
'Electron / Session'
|
|
]);
|
|
const tracesCookies = snapshotContainsRetainingPath(snapshot, [
|
|
'C++ Persistent roots',
|
|
'Electron / Session',
|
|
'Electron / Cookies'
|
|
]);
|
|
return { hasThreeSessionRoots, tracesWindowSession, tracesCookies };
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result.hasThreeSessionRoots, 'each distinct Session should have its own persistent root').toBe(true);
|
|
expect(
|
|
result.tracesWindowSession,
|
|
'WebContents should trace its Session instead of adding a persistent root'
|
|
).toBe(true);
|
|
expect(result.tracesCookies, 'Session should trace its Cookies').toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('cookies module', () => {
|
|
it('should return the same Cookies instance for the same session', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(async () => {
|
|
const { session } = require('electron');
|
|
const ses = session.fromPartition('cookies-identity');
|
|
const cookies1 = ses.cookies;
|
|
const cookies2 = ses.cookies;
|
|
return cookies1 === cookies2;
|
|
});
|
|
expect(result, 'cookies getter should return the same instance').toBe(true);
|
|
});
|
|
|
|
it('should survive GC when only referenced through session', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { session } = require('electron');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
// Access cookies to create the C++ object, then drop the JS reference.
|
|
const ses = session.defaultSession;
|
|
let cookies: any = ses.cookies;
|
|
await cookies.get({});
|
|
cookies = null;
|
|
|
|
// Force GC — the Cookies object should survive because
|
|
// Session traces it via cppgc::Member.
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
|
|
const snapshot = takeHeapSnapshot();
|
|
const stillAlive = snapshotContainsRetainingPath(snapshot, [
|
|
'C++ Persistent roots',
|
|
'Electron / Session',
|
|
'Electron / Cookies'
|
|
]);
|
|
return stillAlive;
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result, 'Cookies should survive GC when traced from Session').toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('downloadItem module', () => {
|
|
it('should survive GC while a download is in progress', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { session } = require('electron');
|
|
const http = require('node:http');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
// Server that sends headers + part of the body, then holds the
|
|
// connection open so the download stays in the IN_PROGRESS state.
|
|
let releaseBody: () => void = () => {};
|
|
const bodyHeld = new Promise<void>((resolve) => {
|
|
releaseBody = resolve;
|
|
});
|
|
const server = http.createServer(async (_req: any, res: any) => {
|
|
res.writeHead(200, {
|
|
'Content-Length': 1024,
|
|
'Content-Type': 'application/octet-stream',
|
|
'Content-Disposition': 'attachment; filename="inflight.bin"'
|
|
});
|
|
res.write(Buffer.alloc(512));
|
|
await bodyHeld;
|
|
res.end(Buffer.alloc(512));
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address();
|
|
|
|
const ses = session.fromPartition('cppheap-download-inflight');
|
|
let item: any = await new Promise((resolve) => {
|
|
ses.once('will-download', (_e: any, downloadItem: any) => resolve(downloadItem));
|
|
ses.downloadURL(`http://127.0.0.1:${port}`);
|
|
});
|
|
item.savePath = path.join(os.tmpdir(), 'cppheap-download-inflight.bin');
|
|
|
|
// Drop the only JS reference while the download is still running.
|
|
item = null;
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
|
|
const snapshot = takeHeapSnapshot();
|
|
const stillAlive = snapshotContainsRetainingPath(snapshot, [
|
|
'C++ Persistent roots',
|
|
'Electron / DownloadItem'
|
|
]);
|
|
|
|
releaseBody();
|
|
server.close();
|
|
return stillAlive;
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result, 'DownloadItem should survive GC while the download is in progress').toBe(true);
|
|
});
|
|
|
|
it('should be released after the download completes', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { session } = require('electron');
|
|
const http = require('node:http');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const payload = Buffer.alloc(1024);
|
|
const server = http.createServer((_req: any, res: any) => {
|
|
res.writeHead(200, {
|
|
'Content-Length': payload.length,
|
|
'Content-Type': 'application/octet-stream',
|
|
'Content-Disposition': 'attachment; filename="done.bin"'
|
|
});
|
|
res.end(payload);
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address();
|
|
|
|
const ses = session.fromPartition('cppheap-download-done');
|
|
await new Promise<void>((resolve) => {
|
|
ses.once('will-download', (_e: any, item: any) => {
|
|
item.savePath = path.join(os.tmpdir(), 'cppheap-download-done.bin');
|
|
item.once('done', () => resolve());
|
|
});
|
|
ses.downloadURL(`http://127.0.0.1:${port}`);
|
|
});
|
|
server.close();
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
|
|
const snapshot = takeHeapSnapshot();
|
|
const found = snapshotContainsRetainingPath(snapshot, ['C++ Persistent roots', 'Electron / DownloadItem']);
|
|
return !found;
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result, 'DownloadItem should be released after the download completes and GC runs').toBe(true);
|
|
});
|
|
|
|
it('should not leak when performing multiple downloads', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(async () => {
|
|
const { session } = require('electron');
|
|
const http = require('node:http');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { getCppHeapStatistics } = require('node:v8');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const payload = Buffer.alloc(1024);
|
|
const server = http.createServer((_req: any, res: any) => {
|
|
res.writeHead(200, {
|
|
'Content-Length': payload.length,
|
|
'Content-Type': 'application/octet-stream',
|
|
'Content-Disposition': 'attachment; filename="leak.bin"'
|
|
});
|
|
res.end(payload);
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address();
|
|
|
|
const ses = session.fromPartition('cppheap-download-leak');
|
|
const savePath = path.join(os.tmpdir(), 'cppheap-download-leak.bin');
|
|
|
|
async function downloadOnce() {
|
|
await new Promise<void>((resolve) => {
|
|
ses.once('will-download', (_e: any, item: any) => {
|
|
item.savePath = savePath;
|
|
item.once('done', () => resolve());
|
|
});
|
|
ses.downloadURL(`http://127.0.0.1:${port}`);
|
|
});
|
|
}
|
|
|
|
async function measure(n: number) {
|
|
for (let i = 0; i < n; i++) {
|
|
await downloadOnce();
|
|
}
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
return getCppHeapStatistics('brief').used_size_bytes;
|
|
}
|
|
|
|
await measure(5);
|
|
const after1 = await measure(10);
|
|
const after2 = await measure(10);
|
|
server.close();
|
|
return { after1, after2 };
|
|
});
|
|
|
|
const growth = result.after2 - result.after1;
|
|
expect(
|
|
growth,
|
|
`C++ heap grew by ${growth} bytes between two identical rounds of downloads — likely a leak`
|
|
).toBeLessThanOrEqual(result.after1 * 0.1);
|
|
});
|
|
|
|
ifit(isTestingBindingAvailable())(
|
|
'does not crash when clearData() runs between collection and finalization',
|
|
async () => {
|
|
const savePath = path.join(os.tmpdir(), 'cppheap-download-unswept.bin');
|
|
defer(() => fs.promises.rm(savePath, { force: true }));
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(async (savePath: string) => {
|
|
const { session } = require('electron');
|
|
const http = require('node:http');
|
|
const testing = (process as any)._linkedBinding('electron_common_testing');
|
|
|
|
const payload = Buffer.alloc(1024);
|
|
const server = http.createServer((_req: any, res: any) => {
|
|
res.writeHead(200, {
|
|
'Content-Length': payload.length,
|
|
'Content-Type': 'application/octet-stream',
|
|
'Content-Disposition': 'attachment; filename="unswept.bin"'
|
|
});
|
|
res.end(payload);
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address();
|
|
|
|
// Once the download is done, only the holder keeps the wrapper alive.
|
|
// The collection task below drops it,
|
|
// so no earlier collection can find it unreachable.
|
|
const holder: { item: any } = { item: null };
|
|
const ses = session.fromPartition('cppheap-download-unswept');
|
|
const { state, weakItem } = await new Promise<{ state: string; weakItem: WeakRef<any> }>((resolve) => {
|
|
ses.once('will-download', (_event: any, item: any) => {
|
|
item.savePath = savePath;
|
|
holder.item = item;
|
|
const weakItem = new WeakRef(item);
|
|
item.once('done', (_doneEvent: any, state: string) => resolve({ state, weakItem }));
|
|
});
|
|
ses.downloadURL(`http://127.0.0.1:${port}`);
|
|
});
|
|
server.close();
|
|
|
|
// The completed download stays in the session's DownloadManager,
|
|
// and clearData() removes it, which notifies its observers.
|
|
// Do that while the wrapper is unreachable but not yet finalized.
|
|
let cleared: Promise<void> | undefined;
|
|
let finalized = false;
|
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
finalized = await testing.collectGarbageBeforeFinalizationForTesting(
|
|
weakItem.deref(),
|
|
() => {
|
|
holder.item = null;
|
|
},
|
|
(itemIsDead: boolean) => {
|
|
if (itemIsDead) cleared = ses.clearData({ dataTypes: ['downloads'] });
|
|
}
|
|
);
|
|
if (cleared) break;
|
|
}
|
|
const collected = cleared !== undefined;
|
|
await cleared;
|
|
return { state, collected, finalized };
|
|
}, savePath);
|
|
expect(result.state).toBe('completed');
|
|
expect(result.collected, 'the DownloadItem should be unreachable when clearData() runs').toBe(true);
|
|
expect(result.finalized, 'the DownloadItem should be finalized after clearData() ran').toBe(true);
|
|
}
|
|
);
|
|
});
|
|
|
|
describe('serviceWorkerMain module', () => {
|
|
const setupWorkerSource = `async function setupWorker(fixturesDir) {
|
|
const { app, session, BrowserWindow } = require('electron');
|
|
const http = require('node:http');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
|
|
app.on('window-all-closed', () => {});
|
|
|
|
const uuid = crypto.randomUUID();
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url, 'http://' + req.headers.host);
|
|
const file = url.pathname.split('/')[2];
|
|
if (file.endsWith('.js')) res.setHeader('Content-Type', 'application/javascript');
|
|
res.end(fs.readFileSync(path.resolve(fixturesDir, file)));
|
|
});
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address();
|
|
const baseUrl = 'http://localhost:' + port + '/' + uuid;
|
|
|
|
const ses = session.fromPartition('cppheap-sw-' + uuid);
|
|
const serviceWorkers = ses.serviceWorkers;
|
|
const win = new BrowserWindow({ show: false, webPreferences: { session: ses } });
|
|
|
|
const versionId = await new Promise((resolve) => {
|
|
const onChange = (event) => {
|
|
serviceWorkers.off('running-status-changed', onChange);
|
|
resolve(event.versionId);
|
|
};
|
|
serviceWorkers.on('running-status-changed', onChange);
|
|
win.webContents.loadURL(baseUrl + '/index.html');
|
|
});
|
|
|
|
return { win, server, ses, serviceWorkers, versionId };
|
|
}`;
|
|
|
|
it('does not crash on exit with a live service worker wrapper', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
const exited = once(rc.process, 'exit');
|
|
await rc.remotely(
|
|
async (fixturesDir: string, setupWorker: string) => {
|
|
const { app } = require('electron');
|
|
// oxlint-disable-next-line no-eval
|
|
const setup = eval('(' + setupWorker + ')');
|
|
const ctx = await setup(fixturesDir);
|
|
|
|
(globalThis as any).swRef = ctx.serviceWorkers.getWorkerFromVersionID(ctx.versionId);
|
|
|
|
setTimeout(() => app.quit());
|
|
},
|
|
path.join(fixturesPath, 'api', 'service-workers'),
|
|
setupWorkerSource
|
|
);
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
it('should be rooted while the service worker is live', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (fixturesDir: string, setupWorker: string, snapshotHelper: string) => {
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
// oxlint-disable-next-line no-eval
|
|
const setup = eval('(' + setupWorker + ')');
|
|
const ctx = await setup(fixturesDir);
|
|
|
|
ctx.serviceWorkers.getWorkerFromVersionID(ctx.versionId);
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
|
|
const snapshot = takeHeapSnapshot();
|
|
const rooted = snapshotContainsRetainingPath(snapshot, [
|
|
'C++ Persistent roots',
|
|
'Electron / ServiceWorkerMain'
|
|
]);
|
|
|
|
ctx.win.destroy();
|
|
ctx.server.close();
|
|
return rooted;
|
|
},
|
|
path.join(fixturesPath, 'api', 'service-workers'),
|
|
setupWorkerSource,
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result, 'ServiceWorkerMain should be rooted via SelfKeepAlive while the version is live').toBe(true);
|
|
});
|
|
|
|
it('should be released after the service worker is unregistered', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (fixturesDir: string, setupWorker: string, snapshotHelper: string) => {
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
// oxlint-disable-next-line no-eval
|
|
const setup = eval('(' + setupWorker + ')');
|
|
const ctx = await setup(fixturesDir);
|
|
|
|
let worker: any = ctx.serviceWorkers.getWorkerFromVersionID(ctx.versionId);
|
|
const { versionId } = ctx;
|
|
|
|
await ctx.win.webContents.executeJavaScript(
|
|
'(async () => { const rs = await navigator.serviceWorker.getRegistrations(); for (const r of rs) await r.unregister(); })()'
|
|
);
|
|
|
|
// Wait until the wrapper has been destroyed.
|
|
for (let i = 0; i < 100; i++) {
|
|
if (worker && worker.isDestroyed()) break;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
|
|
// Drop the JS reference and GC. Neither the SelfKeepAlive root nor a
|
|
// JS wrapper should keep it alive anymore.
|
|
worker = null;
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
|
|
const snapshot = takeHeapSnapshot();
|
|
const rooted = snapshotContainsRetainingPath(snapshot, [
|
|
'C++ Persistent roots',
|
|
'Electron / ServiceWorkerMain'
|
|
]);
|
|
const stillExists = ctx.serviceWorkers._getWorkerFromVersionIDIfExists(versionId) != null;
|
|
|
|
ctx.win.destroy();
|
|
ctx.server.close();
|
|
return !rooted && !stillExists;
|
|
},
|
|
path.join(fixturesPath, 'api', 'service-workers'),
|
|
setupWorkerSource,
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result, 'ServiceWorkerMain should be released after the service worker is unregistered and GC runs').toBe(
|
|
true
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('nativeImage module', () => {
|
|
it('should record as node in heap snapshot while a JS reference is held', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { nativeImage } = require('electron');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
(globalThis as any).nativeImageRef = nativeImage.createEmpty();
|
|
const snapshot = takeHeapSnapshot();
|
|
const present = snapshotContainsRetainingPath(snapshot, ['Electron / NativeImage']);
|
|
const isPersistentRooted = snapshotContainsRetainingPath(snapshot, [
|
|
'C++ Persistent roots',
|
|
'Electron / NativeImage'
|
|
]);
|
|
return present && !isPersistentRooted;
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should be released after GC when no JS references remain', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const released = await remotely(async () => {
|
|
const { nativeImage } = require('electron');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const waitForGC = async (fn: () => boolean) => {
|
|
for (let i = 0; i < 30; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (fn()) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
let image: any = nativeImage.createEmpty();
|
|
const weakRef = new WeakRef(image);
|
|
image = null;
|
|
|
|
return waitForGC(() => weakRef.deref() === undefined);
|
|
});
|
|
expect(released, 'NativeImage should be released after GC when no JS references remain').toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Notification module', () => {
|
|
it('does not crash on exit with a live wrapper', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
const exited = once(rc.process, 'exit');
|
|
await rc.remotely(async () => {
|
|
const { app, Notification } = require('electron');
|
|
(globalThis as any).notificationRef = new Notification({ title: 'cppgc' });
|
|
setTimeout(() => app.quit());
|
|
});
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
it('does not crash when show() runs after its native peer is released at exit', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
const closed = once(rc.process, 'close');
|
|
let stdout = '';
|
|
rc.process.stdout!.on('data', (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
await rc.remotely(async () => {
|
|
const { app, Notification, webContents } = require('electron');
|
|
// Shutdown releases native peers in reverse registration order, so the
|
|
// later Notification is released before the WebContents emits.
|
|
const contents = webContents.create();
|
|
const notification = new Notification({ title: 'cppgc', silent: true });
|
|
contents.on('destroyed', () => {
|
|
notification.show();
|
|
process.stdout.write('notification-show: returned\n');
|
|
});
|
|
(globalThis as any).contents = contents;
|
|
(globalThis as any).notification = notification;
|
|
setTimeout(() => app.quit());
|
|
});
|
|
|
|
const [code] = await closed;
|
|
expect(code).toBe(0);
|
|
expect(stdout).toContain('notification-show: returned');
|
|
});
|
|
|
|
it('should record as node in heap snapshot while a JS reference is held', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { Notification } = require('electron');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
(globalThis as any).notificationRef = new Notification({ title: 'cppgc' });
|
|
const snapshot = takeHeapSnapshot();
|
|
const present = snapshotContainsRetainingPath(snapshot, ['Electron / Notification']);
|
|
const isPersistentRooted = snapshotContainsRetainingPath(snapshot, [
|
|
'C++ Persistent roots',
|
|
'Electron / Notification'
|
|
]);
|
|
return present && !isPersistentRooted;
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should be released after GC when no JS references remain', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const released = await remotely(async () => {
|
|
const { Notification } = require('electron');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const waitForGC = async (fn: () => boolean) => {
|
|
for (let i = 0; i < 30; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (fn()) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
let notification: any = new Notification({ title: 'cppgc' });
|
|
const weakRef = new WeakRef(notification);
|
|
notification = null;
|
|
|
|
return waitForGC(() => weakRef.deref() === undefined);
|
|
});
|
|
expect(released, 'Notification should be released after GC when no JS references remain').toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('View module', () => {
|
|
it('should record as node in heap snapshot while a JS reference is held', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { View } = require('electron');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
(globalThis as any).viewRef = new View();
|
|
const snapshot = takeHeapSnapshot();
|
|
const present = snapshotContainsRetainingPath(snapshot, ['Electron / View']);
|
|
const isPersistentRooted = snapshotContainsRetainingPath(snapshot, [
|
|
'C++ Persistent roots',
|
|
'Electron / View'
|
|
]);
|
|
return present && !isPersistentRooted;
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('releases a View whose layout callback captures it', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const released = await remotely(async () => {
|
|
const { View } = require('electron');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const refs = (() => {
|
|
const parent = new View();
|
|
const child = new View();
|
|
parent.addChildView(child);
|
|
parent.setLayout({
|
|
calculateProposedLayout: () => ({
|
|
size: { width: 10, height: 10 },
|
|
layouts: [{ view: parent.children[0], bounds: { x: 0, y: 0, width: 10, height: 10 } }]
|
|
})
|
|
});
|
|
parent.setBounds({ x: 0, y: 0, width: 20, height: 20 });
|
|
return { parent: new WeakRef(parent), child: new WeakRef(child) };
|
|
})();
|
|
|
|
for (let i = 0; i < 30; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (!refs.parent.deref() && !refs.child.deref()) return true;
|
|
}
|
|
return false;
|
|
});
|
|
expect(released, 'a layout callback that captures its View must not keep it alive').toBe(true);
|
|
});
|
|
|
|
it('keeps child views alive through their parent', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(async () => {
|
|
const { View } = require('electron');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const parent = new View();
|
|
(globalThis as any).parentView = parent;
|
|
const childRef = (() => {
|
|
const child = new View();
|
|
parent.addChildView(child);
|
|
return new WeakRef(child);
|
|
})();
|
|
|
|
for (let i = 0; i < 10; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
return { count: parent.children.length, same: childRef.deref() === parent.children[0] };
|
|
});
|
|
expect(result).toEqual({ count: 1, same: true });
|
|
});
|
|
|
|
it('gives a window content view created before View is loaded the View prototype', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(async () => {
|
|
const electron = require('electron');
|
|
const w = new electron.BaseWindow({ show: false });
|
|
const contentView = w.contentView;
|
|
const { View } = electron;
|
|
const ok = contentView instanceof View && Object.getPrototypeOf(contentView) === View.prototype;
|
|
w.destroy();
|
|
return ok;
|
|
});
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('destroys the webContents of a collected WebContentsView', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(async () => {
|
|
const { WebContentsView } = require('electron');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const state = (() => {
|
|
const view = new WebContentsView();
|
|
return { webContents: view.webContents, view: new WeakRef(view) };
|
|
})();
|
|
|
|
for (let i = 0; i < 30; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (state.webContents.isDestroyed()) break;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
return { collected: !state.view.deref(), destroyed: state.webContents.isDestroyed() };
|
|
});
|
|
expect(result).toEqual({ collected: true, destroyed: true });
|
|
});
|
|
|
|
it('does not destroy a webContents adopted before its previous view is released', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(async () => {
|
|
const { WebContentsView } = require('electron');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const previousViews: WeakRef<Electron.WebContentsView>[] = [];
|
|
let view: Electron.WebContentsView | null = new WebContentsView() as Electron.WebContentsView;
|
|
previousViews.push(new WeakRef(view));
|
|
const webContents = view.webContents;
|
|
for (let i = 0; i < 20; ++i) {
|
|
// Collect the previous view and adopt its webContents in the same
|
|
// task, before the collected view's native peer is released.
|
|
view = null;
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
view = new WebContentsView({ webContents }) as Electron.WebContentsView;
|
|
previousViews.push(new WeakRef(view));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
if (webContents.isDestroyed()) break;
|
|
}
|
|
for (let i = 0; i < 5; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const destroyed = webContents.isDestroyed();
|
|
const anyCollected = previousViews.some((ref) => ref.deref() === undefined);
|
|
if (!destroyed) webContents.destroy();
|
|
return { destroyed, anyCollected };
|
|
});
|
|
expect(result).toEqual({ destroyed: false, anyCollected: true });
|
|
});
|
|
|
|
it('gives an adopted webContents to the view that adopted it', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(async () => {
|
|
const { View, WebContentsView } = require('electron');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
const collect = async () => {
|
|
for (let i = 0; i < 5; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
};
|
|
|
|
const parent = new View();
|
|
const first = new WebContentsView();
|
|
const webContents = first.webContents;
|
|
parent.addChildView(first);
|
|
const adopted = (() => {
|
|
const second = new WebContentsView({ webContents });
|
|
parent.addChildView(second);
|
|
return new WeakRef(second);
|
|
})();
|
|
|
|
await collect();
|
|
const whileRetained = {
|
|
destroyed: webContents.isDestroyed(),
|
|
children: parent.children.length
|
|
};
|
|
parent.removeChildView(adopted.deref()!);
|
|
for (let i = 0; i < 30 && !webContents.isDestroyed(); ++i) await collect();
|
|
const afterRelease = {
|
|
collected: !adopted.deref(),
|
|
destroyed: webContents.isDestroyed(),
|
|
children: parent.children.length
|
|
};
|
|
return { whileRetained, afterRelease, firstInParent: parent.children[0] === first };
|
|
});
|
|
expect(result).toEqual({
|
|
whileRetained: { destroyed: false, children: 2 },
|
|
afterRelease: { collected: true, destroyed: true, children: 0 },
|
|
firstInParent: false
|
|
});
|
|
});
|
|
|
|
it('does not crash on exit with live views', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
const exited = once(rc.process, 'exit');
|
|
await rc.remotely(async () => {
|
|
const { app, BaseWindow, ImageView, View, WebContentsView } = require('electron');
|
|
const w = new BaseWindow({ show: false });
|
|
const container = new View();
|
|
const webContentsView = new WebContentsView();
|
|
container.addChildView(new ImageView());
|
|
container.addChildView(webContentsView);
|
|
container.setLayout({
|
|
calculateProposedLayout: () => ({ size: { width: 0, height: 0 }, layouts: [] })
|
|
});
|
|
w.contentView.addChildView(container);
|
|
(globalThis as any).views = { w, container, detached: new View() };
|
|
setTimeout(() => app.quit());
|
|
});
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('MessagePort module', () => {
|
|
it('should be rooted while a started port is entangled', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { MessageChannelMain } = require('electron');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const { port1, port2 } = new MessageChannelMain();
|
|
port1.start();
|
|
const snapshot = takeHeapSnapshot();
|
|
port2.close();
|
|
return snapshotContainsRetainingPath(snapshot, ['C++ Persistent roots', 'Electron / MessagePort']);
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should be released after a started port is closed', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const released = await remotely(async () => {
|
|
const { MessageChannelMain } = require('electron');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const waitForGC = async (fn: () => boolean) => {
|
|
for (let i = 0; i < 30; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (fn()) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
let { port1, port2 } = new MessageChannelMain();
|
|
port1.start();
|
|
const weakRef = new WeakRef(port1);
|
|
port1.close();
|
|
port1 = null as any;
|
|
|
|
const released = await waitForGC(() => weakRef.deref() === undefined);
|
|
port2.close();
|
|
return released;
|
|
});
|
|
expect(released, 'MessagePort should be released after close and GC').toBe(true);
|
|
});
|
|
|
|
it('does not crash on exit with a live started port', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
const exited = once(rc.process, 'exit');
|
|
await rc.remotely(async () => {
|
|
const { app, MessageChannelMain } = require('electron');
|
|
const { port1, port2 } = new MessageChannelMain();
|
|
port1.start();
|
|
port2.start();
|
|
setTimeout(() => app.quit());
|
|
});
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('url loader module', () => {
|
|
it('should not leak when performing chunked (streaming) uploads', async () => {
|
|
const rc = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const exited = once(rc.process, 'exit');
|
|
const result = await rc.remotely(async () => {
|
|
const { net, app } = require('electron');
|
|
const http = require('node:http');
|
|
const { getCppHeapStatistics } = require('node:v8');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
const server = http.createServer((req: any, res: any) => {
|
|
req.on('data', () => {});
|
|
req.on('end', () => res.end('ok'));
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address();
|
|
|
|
function uploadOnce() {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const request = net.request({
|
|
method: 'POST',
|
|
url: `http://127.0.0.1:${port}`
|
|
});
|
|
request.chunkedEncoding = true;
|
|
request.on('response', (response: any) => {
|
|
response.on('data', () => {});
|
|
response.on('end', () => resolve());
|
|
});
|
|
request.on('error', reject);
|
|
for (let i = 0; i < 4; i++) {
|
|
request.write(Buffer.alloc(256));
|
|
}
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
async function measure(n: number) {
|
|
for (let i = 0; i < n; i++) {
|
|
await uploadOnce();
|
|
}
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
return getCppHeapStatistics('brief').used_size_bytes;
|
|
}
|
|
|
|
await measure(5);
|
|
const after1 = await measure(5);
|
|
const after2 = await measure(5);
|
|
server.close();
|
|
setTimeout(() => app.quit());
|
|
return { after1, after2 };
|
|
});
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
|
|
const growth = result.after2 - result.after1;
|
|
expect(
|
|
growth,
|
|
`C++ heap grew by ${growth} bytes between two identical rounds of chunked uploads — likely a leak`
|
|
).toBeLessThanOrEqual(result.after1 * 0.1);
|
|
});
|
|
|
|
it('completes request after JS drops reference but listeners are registered', async () => {
|
|
const rc = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const exited = once(rc.process, 'exit');
|
|
const result = await rc.remotely(async () => {
|
|
const { net, app } = require('electron');
|
|
const http = require('node:http');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const server = http.createServer((req: any, res: any) => {
|
|
res.writeHead(200);
|
|
res.end('ok');
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address();
|
|
|
|
let completed = false;
|
|
let errorOccurred: string | null = null;
|
|
let requestWeakRef: WeakRef<any>;
|
|
|
|
const done = new Promise<void>((resolve) => {
|
|
let request: any = net.request({ url: `http://127.0.0.1:${port}` });
|
|
requestWeakRef = new WeakRef(request);
|
|
|
|
request.on('response', (response: any) => {
|
|
response.on('data', () => {});
|
|
response.on('end', () => {
|
|
completed = true;
|
|
resolve();
|
|
});
|
|
});
|
|
request.on('error', (err: Error) => {
|
|
errorOccurred = err.message;
|
|
resolve();
|
|
});
|
|
request.end();
|
|
|
|
// Drop the JS reference immediately after registering listeners.
|
|
request = null;
|
|
});
|
|
|
|
// Force GC while request is in flight.
|
|
for (let i = 0; i < 5; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
|
|
await done;
|
|
server.close();
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
const released = requestWeakRef!.deref() === undefined;
|
|
|
|
setTimeout(() => app.quit());
|
|
return { completed, errorOccurred, released };
|
|
});
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
|
|
expect(result.errorOccurred, `request errored: ${result.errorOccurred}`).toBe(null);
|
|
expect(result.completed, 'request should complete even after JS drops reference').toBe(true);
|
|
expect(result.released, 'request should be released after completion').toBe(true);
|
|
});
|
|
|
|
it('keeps a ChunkedDataPipeReadableStream alive while a read is pending and releases it after', async () => {
|
|
const rc = await startRemoteControlApp(['--js-flags=--expose-gc', '--remote-app-timeout=120000']);
|
|
const exited = once(rc.process, 'exit');
|
|
const result = await rc.remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { protocol, net, app } = require('electron');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const gc = async () => {
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
};
|
|
|
|
// Yield to the event loop and run GC until `predicate` becomes true.
|
|
// This waits on the actual condition instead of a fixed delay,
|
|
// returning false only if it never settles within a generous attempt
|
|
// budget. Yielding before collecting lets any pending teardown tasks
|
|
// run so the collection sees no lingering references.
|
|
const gcUntil = async (predicate: () => boolean, attempts = 50) => {
|
|
for (let i = 0; i < attempts; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (predicate()) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
let streamWeakRef: WeakRef<any> | undefined;
|
|
let pendingRead: Promise<number> | undefined;
|
|
let resolveReached: () => void;
|
|
let rejectReached: (err: Error) => void;
|
|
const reached = new Promise<void>((resolve, reject) => {
|
|
resolveReached = resolve;
|
|
rejectReached = reject;
|
|
});
|
|
|
|
// The handler deliberately never responds. Hold on to `callback`: the
|
|
// native side only references it weakly, and if the GC loops below
|
|
// collect it the request fails with ERR_FAILED before `request.end()`
|
|
// and the test would then be measuring a dead request.
|
|
protocol.interceptStreamProtocol('http', (request: any, callback: any) => {
|
|
(globalThis as any).heldInterceptCallback = callback;
|
|
(async () => {
|
|
try {
|
|
const elements = request.uploadData || [];
|
|
const streamElement = elements.find((e: any) => e.type === 'stream');
|
|
if (!streamElement) {
|
|
callback({ statusCode: 400, data: null });
|
|
rejectReached(new Error('request had no stream upload element'));
|
|
return;
|
|
}
|
|
let body: any = streamElement.body;
|
|
streamWeakRef = new WeakRef(body);
|
|
// Drain whatever data is immediately available, then keep a read
|
|
// that parks in net::ERR_IO_PENDING.
|
|
const firstRead = body.read(new Uint8Array(1024));
|
|
const drained = await Promise.race([
|
|
firstRead.then(
|
|
() => true,
|
|
() => true
|
|
),
|
|
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 500))
|
|
]);
|
|
pendingRead = drained ? body.read(new Uint8Array(1024)) : firstRead;
|
|
// Drop the only JS reference to the stream while the read is in
|
|
// flight.
|
|
body = null;
|
|
resolveReached();
|
|
} catch (err) {
|
|
rejectReached(err as Error);
|
|
}
|
|
})();
|
|
});
|
|
|
|
const request = net.request({
|
|
method: 'POST',
|
|
url: 'http://pending-read-host'
|
|
});
|
|
request.chunkedEncoding = true;
|
|
request.on('error', () => {});
|
|
// Write one chunk to start streaming the body, but never end the
|
|
// request so a subsequent read has no data and stays pending.
|
|
request.write(Buffer.from('hello'));
|
|
|
|
try {
|
|
// Wait until the handler has parked a pending read and dropped its
|
|
// only JS reference to the stream.
|
|
await reached;
|
|
|
|
await gc();
|
|
const snapshot = takeHeapSnapshot();
|
|
const aliveWhilePending = snapshotContainsRetainingPath(snapshot, [
|
|
'C++ Persistent roots',
|
|
'Electron / ChunkedDataPipeReadableStream'
|
|
]);
|
|
const refAliveWhilePending = streamWeakRef!.deref() !== undefined;
|
|
// Settle the pending read by ending the request body, then wait for
|
|
// the read itself to complete.
|
|
request.end();
|
|
await pendingRead!.catch(() => {});
|
|
|
|
// Tear down the request so the network stack drops its reference to
|
|
// the upload stream; otherwise the live request keeps it reachable
|
|
// and it can never be collected. The handler never produced a
|
|
// response, so aborting is also the cleanup path.
|
|
request.abort();
|
|
|
|
// Wait until the stream is actually released now that no read is
|
|
// pending and the request has been torn down.
|
|
const released = await gcUntil(() => streamWeakRef!.deref() === undefined);
|
|
|
|
return { ok: true, aliveWhilePending, refAliveWhilePending, released };
|
|
} catch (err) {
|
|
request.abort();
|
|
return { ok: false, error: String(err) };
|
|
} finally {
|
|
delete (globalThis as any).heldInterceptCallback;
|
|
protocol.uninterceptProtocol('http');
|
|
setTimeout(() => app.quit());
|
|
}
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
|
|
expect(result.ok, `test setup failed: ${result.error}`).toBe(true);
|
|
expect(result.refAliveWhilePending, 'stream should still be alive while a read is pending').toBe(true);
|
|
expect(result.aliveWhilePending, 'ChunkedDataPipeReadableStream should be rooted while a read is pending').toBe(
|
|
true
|
|
);
|
|
expect(result.released, 'ChunkedDataPipeReadableStream should be released after the pending read settles').toBe(
|
|
true
|
|
);
|
|
});
|
|
});
|
|
|
|
ifdescribe(isTestingBindingAvailable())('SafeV8Function callback conversion', () => {
|
|
const gcTestArgv = ['--js-flags=--expose-gc'];
|
|
|
|
it('retains repeating callback while held, allows multiple invocations, then releases', async () => {
|
|
const { remotely } = await startRemoteControlApp(gcTestArgv);
|
|
const result = await remotely(async () => {
|
|
const testingBinding = (process as any)._linkedBinding('electron_common_testing');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const waitForGC = async (fn: () => boolean) => {
|
|
for (let i = 0; i < 30; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (fn()) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
let callCount = 0;
|
|
let repeating: any = () => {
|
|
callCount++;
|
|
};
|
|
const repeatingWeakRef = new WeakRef(repeating);
|
|
testingBinding.holdRepeatingCallbackForTesting(repeating);
|
|
repeating = null;
|
|
|
|
const invoked0 = testingBinding.invokeHeldRepeatingCallbackForTesting();
|
|
const invoked1 = testingBinding.invokeHeldRepeatingCallbackForTesting();
|
|
const invoked2 = testingBinding.invokeHeldRepeatingCallbackForTesting();
|
|
|
|
testingBinding.clearHeldCallbacksForTesting();
|
|
const releasedAfterClear = await waitForGC(() => repeatingWeakRef.deref() === undefined);
|
|
|
|
return { invoked0, invoked1, invoked2, callCount, releasedAfterClear };
|
|
});
|
|
|
|
expect(result.invoked0, 'first invocation should succeed').toBe(true);
|
|
expect(result.invoked1, 'second invocation should succeed').toBe(true);
|
|
expect(result.invoked2, 'third invocation should succeed').toBe(true);
|
|
expect(result.callCount, 'callback should have been called 3 times').toBe(3);
|
|
expect(result.releasedAfterClear, 'callback should be released after clear').toBe(true);
|
|
});
|
|
|
|
it('consumes once callback on first invoke and releases it', async () => {
|
|
const { remotely } = await startRemoteControlApp(gcTestArgv);
|
|
const result = await remotely(async () => {
|
|
const testingBinding = (process as any)._linkedBinding('electron_common_testing');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const waitForGC = async (fn: () => boolean) => {
|
|
for (let i = 0; i < 30; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (fn()) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
let callCount = 0;
|
|
let once: any = () => {
|
|
callCount++;
|
|
};
|
|
const onceWeakRef = new WeakRef(once);
|
|
testingBinding.holdOnceCallbackForTesting(once);
|
|
once = null;
|
|
|
|
const first = testingBinding.invokeHeldOnceCallbackForTesting();
|
|
const second = testingBinding.invokeHeldOnceCallbackForTesting();
|
|
|
|
testingBinding.clearHeldCallbacksForTesting();
|
|
const released = await waitForGC(() => onceWeakRef.deref() === undefined);
|
|
|
|
return { first, second, callCount, released };
|
|
});
|
|
|
|
expect(result.first, 'first invoke should succeed').toBe(true);
|
|
expect(result.second, 'second invoke should fail (consumed)').toBe(false);
|
|
expect(result.callCount, 'callback should have been called once').toBe(1);
|
|
expect(result.released, 'callback should be released after consume + clear').toBe(true);
|
|
});
|
|
|
|
it('releases replaced repeating callback while keeping latest callback alive', async () => {
|
|
const { remotely } = await startRemoteControlApp(gcTestArgv);
|
|
const result = await remotely(async () => {
|
|
const testingBinding = (process as any)._linkedBinding('electron_common_testing');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const waitForGC = async (fn: () => boolean) => {
|
|
for (let i = 0; i < 30; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (fn()) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
let callbackA: any = () => {};
|
|
const weakA = new WeakRef(callbackA);
|
|
testingBinding.holdRepeatingCallbackForTesting(callbackA);
|
|
callbackA = null;
|
|
|
|
let callbackB: any = () => {};
|
|
const weakB = new WeakRef(callbackB);
|
|
testingBinding.holdRepeatingCallbackForTesting(callbackB);
|
|
callbackB = null;
|
|
|
|
const releasedA = await waitForGC(() => weakA.deref() === undefined);
|
|
|
|
testingBinding.clearHeldCallbacksForTesting();
|
|
const releasedB = await waitForGC(() => weakB.deref() === undefined);
|
|
|
|
return { releasedA, releasedB };
|
|
});
|
|
|
|
expect(result.releasedA, 'replaced callback A should be released').toBe(true);
|
|
expect(result.releasedB, 'callback B should be released after clear').toBe(true);
|
|
});
|
|
|
|
it('keeps callback alive while copied holder exists and releases after all copies clear', async () => {
|
|
const { remotely } = await startRemoteControlApp(gcTestArgv);
|
|
const result = await remotely(async () => {
|
|
const testingBinding = (process as any)._linkedBinding('electron_common_testing');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const waitForGC = async (fn: () => boolean) => {
|
|
for (let i = 0; i < 30; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (fn()) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
let repeating: any = () => {};
|
|
const weakRef = new WeakRef(repeating);
|
|
testingBinding.holdRepeatingCallbackForTesting(repeating);
|
|
repeating = null;
|
|
|
|
const copied = testingBinding.copyHeldRepeatingCallbackForTesting();
|
|
const countAfterCopy = testingBinding.getHeldRepeatingCallbackCountForTesting();
|
|
testingBinding.clearPrimaryHeldRepeatingCallbackForTesting();
|
|
|
|
const invokedViaCopy = testingBinding.invokeCopiedRepeatingCallbackForTesting();
|
|
|
|
testingBinding.clearHeldCallbacksForTesting();
|
|
const releasedAfterClear = await waitForGC(() => weakRef.deref() === undefined);
|
|
|
|
return { copied, countAfterCopy, invokedViaCopy, releasedAfterClear };
|
|
});
|
|
|
|
expect(result.copied, 'copy should succeed').toBe(true);
|
|
expect(result.countAfterCopy, 'should have 2 holders after copy').toBe(2);
|
|
expect(result.invokedViaCopy, 'invoke via copy should succeed').toBe(true);
|
|
expect(result.releasedAfterClear, 'callback should be released after all copies clear').toBe(true);
|
|
});
|
|
|
|
it('does not leak repeating callback when callback throws during invocation', async () => {
|
|
const { remotely } = await startRemoteControlApp(gcTestArgv);
|
|
const result = await remotely(async () => {
|
|
const testingBinding = (process as any)._linkedBinding('electron_common_testing');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const waitForGC = async (fn: () => boolean) => {
|
|
for (let i = 0; i < 30; ++i) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (fn()) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
let throwing: any = () => {
|
|
throw new Error('expected test throw');
|
|
};
|
|
const weakRef = new WeakRef(throwing);
|
|
testingBinding.holdRepeatingCallbackForTesting(throwing);
|
|
throwing = null;
|
|
|
|
const invokeResult = testingBinding.invokeHeldRepeatingCallbackForTesting();
|
|
|
|
testingBinding.clearHeldCallbacksForTesting();
|
|
const releasedAfterClear = await waitForGC(() => weakRef.deref() === undefined);
|
|
|
|
return { invokeResult, releasedAfterClear };
|
|
});
|
|
|
|
expect(result.invokeResult, 'invoke should fail (callback throws)').toBe(false);
|
|
expect(result.releasedAfterClear, 'throwing callback should be released after clear').toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('menu module', () => {
|
|
// Regression test for https://github.com/electron/electron/issues/50791
|
|
it('should not leak when rebuilding application menu', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(async () => {
|
|
const { Menu } = require('electron');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
const { getCppHeapStatistics } = require('node:v8');
|
|
|
|
function buildLargeMenu() {
|
|
return Menu.buildFromTemplate(
|
|
Array.from({ length: 10 }, (_, i) => ({
|
|
label: `Menu ${i}`,
|
|
submenu: Array.from({ length: 20 }, (_, j) => ({
|
|
label: `Item ${i}-${j}`,
|
|
click: () => {}
|
|
}))
|
|
}))
|
|
);
|
|
}
|
|
|
|
function rebuild(n: number) {
|
|
for (let i = 0; i < n; i++) {
|
|
Menu.setApplicationMenu(buildLargeMenu());
|
|
}
|
|
}
|
|
|
|
async function rebuildAndMeasure(n: number) {
|
|
rebuild(n);
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await v8Util.requestGarbageCollectionForTesting({ execution: 'async' });
|
|
}
|
|
return getCppHeapStatistics('brief').used_size_bytes;
|
|
}
|
|
|
|
await rebuildAndMeasure(50);
|
|
const after1 = await rebuildAndMeasure(50);
|
|
const after2 = await rebuildAndMeasure(50);
|
|
return { after1, after2 };
|
|
});
|
|
|
|
const growth = result.after2 - result.after1;
|
|
expect(
|
|
growth,
|
|
`C++ heap grew by ${growth} bytes between two identical rounds of 50 menu rebuilds — likely a leak`
|
|
).toBeLessThanOrEqual(result.after1 * 0.1);
|
|
});
|
|
});
|
|
|
|
describe('webContents module', () => {
|
|
it('rejects pending debugger commands after its wrapper is collected', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(async () => {
|
|
const { webContents } = require('electron');
|
|
const v8Util = process._linkedBinding('electron_common_v8_util');
|
|
const state = await (async () => {
|
|
const contents = webContents.create();
|
|
await contents.loadURL('about:blank');
|
|
contents.debugger.attach();
|
|
const pending = contents.debugger.sendCommand('Runtime.evaluate', {
|
|
expression: 'new Promise(() => {})',
|
|
awaitPromise: true
|
|
});
|
|
return { pending, debuggerRef: new WeakRef(contents.debugger) };
|
|
})();
|
|
let settled = 'pending';
|
|
state.pending.then(
|
|
() => {
|
|
settled = 'resolved';
|
|
},
|
|
(error: Error) => {
|
|
settled = error.message;
|
|
}
|
|
);
|
|
for (let attempt = 0; attempt < 30; ++attempt) {
|
|
if (settled !== 'pending') break;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
return { collected: !state.debuggerRef.deref(), settled };
|
|
});
|
|
|
|
expect(result).toEqual({ collected: true, settled: 'target closed while handling command' });
|
|
});
|
|
|
|
it('emits debugger detach exactly once at exit', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
const closed = once(rc.process, 'close');
|
|
let stdout = '';
|
|
rc.process.stdout!.on('data', (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
await rc.remotely(async () => {
|
|
const { app, webContents } = require('electron');
|
|
const contents = webContents.create();
|
|
await contents.loadURL('about:blank');
|
|
contents.debugger.attach();
|
|
contents.debugger.on('detach', (_event: unknown, reason: string) => {
|
|
let attach = 'did not throw';
|
|
try {
|
|
contents.debugger.attach();
|
|
} catch (error) {
|
|
attach = (error as Error).message;
|
|
}
|
|
process.stdout.write(`debugger-detach: ${reason}; attach: ${attach}\n`);
|
|
});
|
|
(globalThis as any).contents = contents;
|
|
setTimeout(() => app.quit());
|
|
});
|
|
|
|
const [code] = await closed;
|
|
expect(code).toBe(0);
|
|
expect(stdout.match(/^debugger-detach: .*$/gm)).toEqual([
|
|
'debugger-detach: target closed; attach: No target available'
|
|
]);
|
|
});
|
|
|
|
it('drops debugger protocol messages after its wrapper is collected', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--stress-incremental-marking']);
|
|
const collected = await remotely(async () => {
|
|
const { webContents } = require('electron');
|
|
|
|
const debuggerRef = await (async () => {
|
|
const contents = webContents.create();
|
|
await contents.loadURL(
|
|
'data:text/html,<script>setInterval(() => console.log("protocol traffic"), 10)</script>'
|
|
);
|
|
contents.debugger.attach();
|
|
await contents.debugger.sendCommand('Runtime.enable');
|
|
return new WeakRef(contents.debugger);
|
|
})();
|
|
|
|
const pressure: object[][] = [];
|
|
for (let attempt = 0; attempt < 200; ++attempt) {
|
|
pressure.push(
|
|
Array.from({ length: 5000 }, (_, index) => ({
|
|
value: `${attempt}:${index}`
|
|
}))
|
|
);
|
|
if (pressure.length > 8) pressure.shift();
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
return !debuggerRef.deref();
|
|
});
|
|
|
|
expect(collected).toBe(true);
|
|
});
|
|
|
|
it('detaches a retained debugger before collecting it with an unowned WebContents', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { webContents } = require('electron');
|
|
const { countHeapSnapshotNodes, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = process._linkedBinding('electron_common_v8_util');
|
|
const countContents = () =>
|
|
countHeapSnapshotNodes(takeHeapSnapshot(), { contents: { name: 'Electron / WebContents' } }).contents;
|
|
const before = countContents();
|
|
let detachedEvents = 0;
|
|
const onDetach = () => {
|
|
detachedEvents++;
|
|
};
|
|
const state = await (async () => {
|
|
const contents = webContents.create();
|
|
await contents.loadURL('about:blank');
|
|
contents.on('test-cycle', () => contents.id);
|
|
const debuggerApi = contents.debugger;
|
|
debuggerApi.attach();
|
|
debuggerApi.on('detach', onDetach);
|
|
return {
|
|
contents: new WeakRef(contents),
|
|
debugger: debuggerApi as typeof debuggerApi | null,
|
|
debuggerRef: new WeakRef(debuggerApi),
|
|
frame: contents.mainFrame,
|
|
id: contents.id
|
|
};
|
|
})();
|
|
|
|
for (let attempt = 0; attempt < 30; ++attempt) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (detachedEvents === 1) break;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
state.debugger = null;
|
|
for (let attempt = 0; attempt < 30; ++attempt) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
let frameError = '';
|
|
try {
|
|
frameError = `Frame is still live: ${state.frame.url}`;
|
|
} catch (error) {
|
|
frameError = (error as Error).message;
|
|
}
|
|
return {
|
|
released: !state.contents.deref() && !state.debuggerRef.deref(),
|
|
removedFromRegistry: webContents.fromId(state.id) === undefined,
|
|
detachedEvents,
|
|
frameError,
|
|
before,
|
|
after: countContents()
|
|
};
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
|
|
expect(result.released).toBe(true);
|
|
expect(result.removedFromRegistry).toBe(true);
|
|
expect(result.detachedEvents).toBe(1);
|
|
expect(result.frameError).toContain('Render frame was disposed');
|
|
expect(result.after).toBe(result.before);
|
|
});
|
|
|
|
it('defers guest destruction and emits destroyed only once', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(async () => {
|
|
const { webContents } = require('electron');
|
|
const { once } = require('node:events');
|
|
const embedder = webContents.create();
|
|
const guest = webContents.create({ type: 'webview', embedder });
|
|
const type = guest.getType();
|
|
let destroyedEvents = 0;
|
|
guest.on('destroyed', () => {
|
|
destroyedEvents++;
|
|
});
|
|
const destroyed = once(guest, 'destroyed', { signal: AbortSignal.timeout(10000) });
|
|
guest.destroy();
|
|
const destroyedOnReturn = guest.isDestroyed();
|
|
const eventsOnReturn = destroyedEvents;
|
|
guest.destroy();
|
|
await destroyed;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const embedderDestroyed = once(embedder, 'destroyed', { signal: AbortSignal.timeout(10000) });
|
|
embedder.destroy();
|
|
await embedderDestroyed;
|
|
return { type, destroyedOnReturn, eventsOnReturn, destroyedEvents, destroyed: guest.isDestroyed() };
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
type: 'webview',
|
|
destroyedOnReturn: false,
|
|
eventsOnReturn: 0,
|
|
destroyedEvents: 1,
|
|
destroyed: true
|
|
});
|
|
});
|
|
|
|
it('does not retain an attached guest through its embedder', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(async () => {
|
|
const { webContents } = require('electron');
|
|
const v8Util = process._linkedBinding('electron_common_v8_util');
|
|
const state = await (async () => {
|
|
const embedder = webContents.create();
|
|
await embedder.loadURL('data:text/html,<iframe src="about:blank"></iframe>');
|
|
const guest = (webContents as typeof ElectronInternal.WebContents).create({
|
|
type: 'webview',
|
|
embedder
|
|
});
|
|
await guest.loadURL('about:blank');
|
|
guest.attachToIframe(embedder, embedder.mainFrame.frames[0].frameToken);
|
|
return {
|
|
embedder: new WeakRef(embedder),
|
|
embedderId: embedder.id,
|
|
guest: new WeakRef(guest),
|
|
guestId: guest.id
|
|
};
|
|
})();
|
|
|
|
for (let attempt = 0; attempt < 60; ++attempt) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
return {
|
|
embedderReleased: !state.embedder.deref(),
|
|
embedderRemovedFromRegistry: webContents.fromId(state.embedderId) === undefined,
|
|
guestReleased: !state.guest.deref(),
|
|
guestRemovedFromRegistry: webContents.fromId(state.guestId) === undefined
|
|
};
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
embedderReleased: true,
|
|
embedderRemovedFromRegistry: true,
|
|
guestReleased: true,
|
|
guestRemovedFromRegistry: true
|
|
});
|
|
});
|
|
|
|
it('disposes frame wrappers when an attached guest wrapper is destroyed', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(async () => {
|
|
const { webContents } = require('electron');
|
|
const { once } = require('node:events');
|
|
const v8Util = process._linkedBinding('electron_common_v8_util');
|
|
const embedder = webContents.create();
|
|
|
|
const frameState = await (async () => {
|
|
await embedder.loadURL('data:text/html,<iframe src="about:blank"></iframe>');
|
|
const embedderFrame = embedder.mainFrame.frames[0];
|
|
const guest = (webContents as typeof ElectronInternal.WebContents).create({
|
|
type: 'webview',
|
|
embedder
|
|
});
|
|
await guest.loadURL('about:blank');
|
|
guest.attachToIframe(embedder, embedderFrame.frameToken);
|
|
|
|
const guestFrame = guest.mainFrame;
|
|
const destroyed = once(guest, 'destroyed', { signal: AbortSignal.timeout(10000) });
|
|
guest.destroy();
|
|
await destroyed;
|
|
|
|
let frameError = '';
|
|
try {
|
|
frameError = `Frame is still live: ${guestFrame.url}`;
|
|
} catch (error) {
|
|
frameError = (error as Error).message;
|
|
}
|
|
return {
|
|
frameDestroyed: guestFrame.isDestroyed(),
|
|
frameError,
|
|
frameRef: new WeakRef(guestFrame)
|
|
};
|
|
})();
|
|
|
|
for (let attempt = 0; attempt < 30; ++attempt) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (!frameState.frameRef.deref()) break;
|
|
}
|
|
|
|
const frameReleased = !frameState.frameRef.deref();
|
|
const embedderDestroyed = once(embedder, 'destroyed', { signal: AbortSignal.timeout(10000) });
|
|
embedder.destroy();
|
|
await embedderDestroyed;
|
|
|
|
return {
|
|
frameDestroyed: frameState.frameDestroyed,
|
|
frameError: frameState.frameError,
|
|
frameReleased
|
|
};
|
|
});
|
|
|
|
expect(result.frameDestroyed).toBe(true);
|
|
expect(result.frameError).toContain('Render frame was disposed');
|
|
expect(result.frameReleased, 'the attached guest frame should release its SelfKeepAlive root').toBe(true);
|
|
});
|
|
|
|
it('disposes frame wrappers when a background page wrapper is destroyed', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (extensionPath: string) => {
|
|
const { app, session } = require('electron');
|
|
const { randomUUID } = require('node:crypto');
|
|
const { once } = require('node:events');
|
|
const customSession = session.fromPartition(`persist:cppgc-background-page-${randomUUID()}`);
|
|
const created = once(app, 'web-contents-created', { signal: AbortSignal.timeout(10000) });
|
|
const extension = await customSession.extensions.loadExtension(extensionPath);
|
|
const [, backgroundPage] = await created;
|
|
|
|
try {
|
|
if (backgroundPage.isLoading()) {
|
|
await once(backgroundPage, 'did-finish-load', { signal: AbortSignal.timeout(10000) });
|
|
}
|
|
const type = backgroundPage.getType();
|
|
const frame = backgroundPage.mainFrame;
|
|
const destroyed = once(backgroundPage, 'destroyed', { signal: AbortSignal.timeout(10000) });
|
|
backgroundPage.destroy();
|
|
await destroyed;
|
|
|
|
let frameError = '';
|
|
try {
|
|
frameError = `Frame is still live: ${frame.url}`;
|
|
} catch (error) {
|
|
frameError = (error as Error).message;
|
|
}
|
|
return {
|
|
type,
|
|
frameDestroyed: frame.isDestroyed(),
|
|
frameError
|
|
};
|
|
} finally {
|
|
await customSession.extensions.removeExtension(extension.id);
|
|
}
|
|
},
|
|
path.join(fixturesPath, 'extensions', 'persistent-background-page')
|
|
);
|
|
|
|
expect(result.type).toBe('backgroundPage');
|
|
expect(result.frameDestroyed).toBe(true);
|
|
expect(result.frameError).toContain('Render frame was disposed');
|
|
});
|
|
|
|
it('retains WebContents through their WebContentsView until native destruction', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { WebContentsView } = require('electron');
|
|
const { once } = require('node:events');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = process._linkedBinding('electron_common_v8_util');
|
|
const view = new WebContentsView();
|
|
const ref = new WeakRef(view.webContents);
|
|
|
|
for (let attempt = 0; attempt < 5; ++attempt) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
const retained = ref.deref() === view.webContents && !view.webContents.isDestroyed();
|
|
const retainedByView = snapshotContainsRetainingPath(takeHeapSnapshot(), [
|
|
'Electron / WebContentsView',
|
|
'Electron / WebContents'
|
|
]);
|
|
const destroyed = once(view.webContents, 'destroyed', { signal: AbortSignal.timeout(10000) });
|
|
view.webContents.close();
|
|
await destroyed;
|
|
|
|
for (let attempt = 0; attempt < 30; ++attempt) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (!ref.deref()) break;
|
|
}
|
|
return { retained, retainedByView, released: !ref.deref() };
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
|
|
expect(result.retained).toBe(true);
|
|
expect(result.retainedByView).toBe(true);
|
|
expect(result.released).toBe(true);
|
|
});
|
|
|
|
it('invalidates native methods while a destroyed JS wrapper remains reachable', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { webContents } = require('electron');
|
|
const { once } = require('node:events');
|
|
const { countHeapSnapshotNodes, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = process._linkedBinding('electron_common_v8_util');
|
|
const countContents = () =>
|
|
countHeapSnapshotNodes(takeHeapSnapshot(), { contents: { name: 'Electron / WebContents' } }).contents;
|
|
const before = countContents();
|
|
const contents = webContents.create();
|
|
const id = contents.id;
|
|
let destroyedEvents = 0;
|
|
let destroyedDuringEvent = false;
|
|
contents.on('destroyed', () => {
|
|
destroyedEvents++;
|
|
destroyedDuringEvent = contents.isDestroyed();
|
|
});
|
|
const destroyed = once(contents, 'destroyed', { signal: AbortSignal.timeout(10000) });
|
|
contents.destroy();
|
|
contents.destroy();
|
|
await destroyed;
|
|
|
|
for (let attempt = 0; attempt < 10; ++attempt) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
let methodError = '';
|
|
try {
|
|
contents.getURL();
|
|
} catch (error) {
|
|
methodError = (error as Error).message;
|
|
}
|
|
const after = countContents();
|
|
return {
|
|
destroyedEvents,
|
|
destroyedDuringEvent,
|
|
methodError,
|
|
idPreserved: contents.id === id,
|
|
removedFromRegistry: webContents.fromId(id) === undefined,
|
|
isDestroyed: contents.isDestroyed(),
|
|
before,
|
|
after
|
|
};
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
|
|
expect(result.destroyedEvents).toBe(1);
|
|
expect(result.destroyedDuringEvent).toBe(true);
|
|
expect(result.methodError).toBe('Object has been destroyed');
|
|
expect(result.idPreserved).toBe(true);
|
|
expect(result.removedFromRegistry).toBe(true);
|
|
expect(result.isDestroyed).toBe(true);
|
|
expect(result.after).toBe(result.before);
|
|
});
|
|
|
|
it('releases the remote DevTools root when DevTools closes', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { webContents } = require('electron');
|
|
const { once } = require('node:events');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = process._linkedBinding('electron_common_v8_util');
|
|
const contents = webContents.create();
|
|
await contents.loadURL('about:blank');
|
|
const opened = once(contents, 'devtools-opened', { signal: AbortSignal.timeout(10000) });
|
|
contents.openDevTools({ mode: 'detach', activate: false });
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
await opened;
|
|
const ref = new WeakRef(contents.devToolsWebContents);
|
|
const rooted = snapshotContainsRetainingPath(takeHeapSnapshot(), [
|
|
'C++ Persistent roots',
|
|
'Electron / WebContents'
|
|
]);
|
|
const closed = once(contents, 'devtools-closed', { signal: AbortSignal.timeout(10000) });
|
|
contents.closeDevTools();
|
|
await closed;
|
|
|
|
for (let attempt = 0; attempt < 30; ++attempt) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (!ref.deref()) break;
|
|
}
|
|
const released = !ref.deref();
|
|
const rootReleased = !snapshotContainsRetainingPath(takeHeapSnapshot(), [
|
|
'C++ Persistent roots',
|
|
'Electron / WebContents'
|
|
]);
|
|
const destroyed = once(contents, 'destroyed', { signal: AbortSignal.timeout(10000) });
|
|
contents.close();
|
|
await destroyed;
|
|
return { rooted, released, rootReleased };
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
|
|
expect(result.rooted).toBe(true);
|
|
expect(result.released).toBe(true);
|
|
expect(result.rootReleased).toBe(true);
|
|
});
|
|
|
|
it('stops DevTools indexing without retaining cppgc handles on its worker sequence', async () => {
|
|
const rc = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const exited = once(rc.process, 'exit');
|
|
await rc.remotely(async () => {
|
|
const { app, webContents } = require('electron');
|
|
const { once } = require('node:events');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const v8Util = process._linkedBinding('electron_common_v8_util');
|
|
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'electron-devtools-index-'));
|
|
const contents = webContents.create();
|
|
|
|
try {
|
|
for (let i = 0; i < 500; ++i) {
|
|
fs.writeFileSync(path.join(workspace, `${i}.txt`), `indexed content ${i}`);
|
|
}
|
|
|
|
await contents.loadURL('about:blank');
|
|
const opened = once(contents, 'devtools-opened', { signal: AbortSignal.timeout(10000) });
|
|
contents.openDevTools({ mode: 'detach', activate: false });
|
|
await opened;
|
|
contents.addWorkSpace(workspace);
|
|
|
|
const devTools = contents.devToolsWebContents;
|
|
await devTools.executeJavaScript(`InspectorFrontendHost.indexPath(42, ${JSON.stringify(workspace)}, '[]')`);
|
|
await devTools.executeJavaScript('InspectorFrontendHost.stopIndexing(42)');
|
|
|
|
const destroyed = once(contents, 'destroyed', { signal: AbortSignal.timeout(10000) });
|
|
contents.destroy();
|
|
await destroyed;
|
|
for (let attempt = 0; attempt < 10; ++attempt) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
} finally {
|
|
fs.rmSync(workspace, { recursive: true, force: true });
|
|
}
|
|
|
|
setTimeout(() => app.quit());
|
|
});
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
for (const collect of [false, true]) {
|
|
it(`does not crash on exit with ${collect ? 'collected' : 'live'} WebContents`, async () => {
|
|
const rc = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const exited = once(rc.process, 'exit');
|
|
await rc.remotely(async (collect: boolean) => {
|
|
const { app, webContents } = require('electron');
|
|
const v8Util = process._linkedBinding('electron_common_v8_util');
|
|
if (collect) {
|
|
const ref = (() => {
|
|
const contents = webContents.create();
|
|
return new WeakRef(contents);
|
|
})();
|
|
for (let attempt = 0; attempt < 30; ++attempt) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
if (!ref.deref()) break;
|
|
}
|
|
if (ref.deref()) {
|
|
app.exit(1);
|
|
return;
|
|
}
|
|
} else {
|
|
(globalThis as any).contents = webContents.create();
|
|
}
|
|
setTimeout(() => app.quit());
|
|
}, collect);
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
});
|
|
}
|
|
|
|
it('destroys a live WebContentsView before its Session at exit', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
const exited = once(rc.process, 'exit');
|
|
await rc.remotely(async () => {
|
|
const { app, WebContentsView } = require('electron');
|
|
const view = new WebContentsView({
|
|
webPreferences: {
|
|
partition: `persist:shutdown-order-${process.pid}`
|
|
}
|
|
});
|
|
const session = view.webContents.session;
|
|
view.webContents.on('destroyed', () => {
|
|
session.getUserAgent();
|
|
session.getStoragePath();
|
|
});
|
|
(globalThis as any).view = view;
|
|
setTimeout(() => app.quit());
|
|
});
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
it('makes wrappers inert before shutdown cleanup regardless of registration order', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
const closed = once(rc.process, 'close');
|
|
let stdout = '';
|
|
rc.process.stdout!.on('data', (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
await rc.remotely(async () => {
|
|
const { app, session, WebContentsView } = require('electron');
|
|
const view = new WebContentsView();
|
|
const laterSession = session.fromPartition(`persist:shutdown-later-${process.pid}`);
|
|
// app.exit() cannot change the exit code this late in shutdown, so
|
|
// report through stdout instead.
|
|
view.webContents.on('destroyed', () => {
|
|
let result = 'did not throw';
|
|
try {
|
|
laterSession.getUserAgent();
|
|
} catch (error) {
|
|
result = (error as Error).message;
|
|
}
|
|
process.stdout.write(`inert-session: ${result}\n`);
|
|
});
|
|
(globalThis as any).view = view;
|
|
(globalThis as any).laterSession = laterSession;
|
|
setTimeout(() => app.quit());
|
|
});
|
|
|
|
const [code] = await closed;
|
|
expect(code).toBe(0);
|
|
expect(stdout).toContain('inert-session: Object has been destroyed');
|
|
});
|
|
});
|
|
|
|
describe('webFrameMain module', () => {
|
|
it('does not crash on exit with live frame wrappers', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
const exited = once(rc.process, 'exit');
|
|
await rc.remotely(async () => {
|
|
const { app, BrowserWindow } = require('electron');
|
|
|
|
const w = new BrowserWindow({ show: false });
|
|
await w.loadURL('about:blank');
|
|
|
|
(globalThis as any).frameRefs = [w.webContents.mainFrame, ...w.webContents.mainFrame.framesInSubtree];
|
|
|
|
setTimeout(() => app.quit());
|
|
});
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
it('should be rooted while the frame is live', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { BrowserWindow } = require('electron');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const w = new BrowserWindow({ show: false });
|
|
await w.loadURL('about:blank');
|
|
|
|
// Access a property to materialize the lazy WebFrameMain wrapper.
|
|
console.log(w.webContents.mainFrame.url);
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
|
|
const snapshot = takeHeapSnapshot();
|
|
const rooted = snapshotContainsRetainingPath(snapshot, ['C++ Persistent roots', 'Electron / WebFrameMain']);
|
|
|
|
w.destroy();
|
|
return rooted;
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result, 'WebFrameMain should stay rooted via SelfKeepAlive while the frame is live').toBe(true);
|
|
});
|
|
|
|
it('should be released after the frame is destroyed', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { app, BrowserWindow } = require('electron');
|
|
const { countRetainingPathNodes, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
// Keep the app alive when the window closes so we can snapshot.
|
|
app.on('window-all-closed', () => {});
|
|
|
|
const collectGarbage = async () => {
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
};
|
|
|
|
const w = new BrowserWindow({ show: false });
|
|
await w.loadURL('data:text/html,<iframe src="about:blank"></iframe>');
|
|
|
|
const mainFrame = w.webContents.mainFrame;
|
|
// Access a property to materialize the lazy subframe wrappers.
|
|
const subframes = mainFrame.frames;
|
|
|
|
await w.loadURL('about:blank');
|
|
await collectGarbage();
|
|
const mainFrameIsActive = w.webContents.mainFrame === mainFrame && !mainFrame.detached;
|
|
const remainingSubframeCount = mainFrame.frames.length;
|
|
const subframeIsDetached = subframes[0]?.detached;
|
|
const hasOneFrame =
|
|
countRetainingPathNodes(takeHeapSnapshot(), ['C++ Persistent roots', 'Electron / WebFrameMain']) === 1;
|
|
|
|
w.destroy();
|
|
return {
|
|
subframeCount: subframes.length,
|
|
mainFrameIsActive,
|
|
remainingSubframeCount,
|
|
subframeIsDetached,
|
|
hasOneFrame
|
|
};
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result.subframeCount, 'a subframe WebFrameMain should be created before navigation').toBe(1);
|
|
expect(result.mainFrameIsActive, 'the remaining WebFrameMain should be the active main frame').toBe(true);
|
|
expect(result.remainingSubframeCount, 'the active main frame should have no subframes after navigation').toBe(0);
|
|
expect(result.subframeIsDetached, 'the subframe WebFrameMain should be detached after navigation').toBe(true);
|
|
expect(
|
|
result.hasOneFrame,
|
|
'subframe WebFrameMain should be released after its frame is destroyed by navigation'
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should release the root for a detached frame after navigation', async () => {
|
|
// The test waits for an IPC from the old page's unload handler. Once the
|
|
// cross-origin page commits, Chromium backgrounds the old renderer, which
|
|
// can then miss the 500 ms unload timeout and drop the IPC (see 'is
|
|
// detached when unload handler sends IPC' in api-web-frame-main.spec.ts).
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc', '--disable-renderer-backgrounding']);
|
|
const result = await remotely(
|
|
async (snapshotHelper: string, preload: string) => {
|
|
const { BrowserWindow, ipcMain } = require('electron');
|
|
const { once } = require('node:events');
|
|
const http = require('node:http');
|
|
const { countRetainingPathNodes, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const createServer = async () => {
|
|
const server = http.createServer((_request: any, response: any) => {
|
|
// Chromium deprecates unload handlers on http(s) pages by default.
|
|
response.setHeader('permissions-policy', 'unload=*');
|
|
response.end('<body></body>');
|
|
});
|
|
server.listen(0, '127.0.0.1');
|
|
await once(server, 'listening');
|
|
return server;
|
|
};
|
|
|
|
const server = await createServer();
|
|
const getUrl = (server: any) => `http://127.0.0.1:${server.address().port}`;
|
|
const url = getUrl(server);
|
|
const crossOriginUrl = url.replace('127.0.0.1', 'localhost');
|
|
const w = new BrowserWindow({ show: false, webPreferences: { preload } });
|
|
|
|
try {
|
|
await w.loadURL(url);
|
|
// Access a property to materialize the lazy WebFrameMain wrapper.
|
|
console.log(w.webContents.mainFrame.url);
|
|
|
|
let detachedFrame: Electron.WebFrameMain | null = null;
|
|
const unloaded = new Promise<void>((resolve) => {
|
|
ipcMain.once('preload-unload', (event: Electron.IpcMainEvent) => {
|
|
detachedFrame = event.senderFrame;
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
const navigated = w.loadURL(crossOriginUrl);
|
|
await unloaded;
|
|
await navigated;
|
|
|
|
// Ensure the detached frame has been destroyed before triggering GC
|
|
for (let i = 0; i < 100; i++) {
|
|
if (detachedFrame!.isDestroyed()) break;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
|
|
console.log(detachedFrame!.detached);
|
|
return (
|
|
countRetainingPathNodes(takeHeapSnapshot(), ['C++ Persistent roots', 'Electron / WebFrameMain']) === 1
|
|
);
|
|
} finally {
|
|
w.destroy();
|
|
server.close();
|
|
}
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js'),
|
|
path.join(fixturesPath, 'sub-frames', 'preload.js')
|
|
);
|
|
expect(result, 'only the active WebFrameMain should remain rooted after navigation').toBe(true);
|
|
});
|
|
|
|
it('should settle an in-flight call stack reply after frame disposal', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(async () => {
|
|
const { app, BrowserWindow } = require('electron');
|
|
const { once } = require('node:events');
|
|
const http = require('node:http');
|
|
|
|
app.on('window-all-closed', () => {});
|
|
|
|
const server = http.createServer((request: any, response: any) => {
|
|
response.setHeader('Document-Policy', 'include-js-call-stacks-in-crash-reports');
|
|
response.end(request.url === '/child' ? '<body></body>' : '<iframe src="/child"></iframe>');
|
|
});
|
|
server.listen(0, '127.0.0.1');
|
|
await once(server, 'listening');
|
|
|
|
const w = new BrowserWindow({ show: false });
|
|
try {
|
|
await w.loadURL(`http://127.0.0.1:${server.address().port}`);
|
|
|
|
// The renderer collects the stack from a V8 interrupt, which only
|
|
// runs while JavaScript is executing. Keep the renderer's isolate
|
|
// running script so the request is answered whenever it arrives,
|
|
// rather than only if it lands before the frame removal below.
|
|
await w.webContents.mainFrame.executeJavaScript('void setInterval(() => {}, 1)');
|
|
|
|
const childFrame = w.webContents.mainFrame.frames[0];
|
|
const callStackPromise = childFrame.collectJavaScriptCallStack();
|
|
const settled = callStackPromise.then(
|
|
() => 'resolved',
|
|
(error: Error) => `rejected: ${error.message}`
|
|
);
|
|
|
|
childFrame.executeJavaScript('window.frameElement.remove()').catch(() => {});
|
|
return await settled;
|
|
} finally {
|
|
w.destroy();
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
// We're creating a race so the promise can either resolve or reject depending
|
|
// on timing, but it should always settle and not have a timeout occur
|
|
expect(['resolved', 'rejected: Render frame was disposed before call stack was received']).toContain(result);
|
|
});
|
|
});
|
|
|
|
describe('internal event', () => {
|
|
it('should record as node in heap snapshot', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { BrowserWindow } = require('electron');
|
|
const { once } = require('node:events');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
|
|
const w = new BrowserWindow({
|
|
show: false
|
|
});
|
|
await w.loadURL('about:blank');
|
|
const snapshot = takeHeapSnapshot();
|
|
const isClosed = once(w, 'closed');
|
|
w.destroy();
|
|
await isClosed;
|
|
const eventNativeStackReference = snapshotContainsRetainingPath(snapshot, [
|
|
'C++ native stack roots',
|
|
'Electron / Event'
|
|
]);
|
|
const noPersistentReference = !snapshotContainsRetainingPath(snapshot, [
|
|
'C++ Persistent roots',
|
|
'Electron / Event'
|
|
]);
|
|
return eventNativeStackReference && noPersistentReference;
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('powerMonitor module', () => {
|
|
it('should retain native PowerMonitor via JS module reference', async () => {
|
|
const rc = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const exited = once(rc.process, 'exit');
|
|
const result = await rc.remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { powerMonitor, app } = require('electron');
|
|
const { countHeapSnapshotNodes, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
// Register a listener to trigger native PowerMonitor creation.
|
|
const listener = () => {};
|
|
powerMonitor.on('suspend', listener);
|
|
|
|
// Add and remove several listeners to exercise the path,
|
|
// then GC to ensure no duplicate native objects are created.
|
|
for (let i = 0; i < 5; i++) {
|
|
const tmp = () => {};
|
|
powerMonitor.on('resume', tmp);
|
|
powerMonitor.removeListener('resume', tmp);
|
|
}
|
|
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
|
|
const { count } = countHeapSnapshotNodes(takeHeapSnapshot(), {
|
|
count: { name: 'Electron / PowerMonitor' }
|
|
});
|
|
const found = count > 0;
|
|
const noDuplicates = count === 1;
|
|
|
|
setTimeout(() => app.quit());
|
|
return { found, noDuplicates };
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
expect(result.found, 'PowerMonitor should be in snapshot (held by JS module)').toBe(true);
|
|
expect(result.noDuplicates, 'should have exactly one PowerMonitor instance').toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('utilityProcess module', () => {
|
|
it('should appear in heap snapshot while process is running', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string, fixturePath: string) => {
|
|
const { utilityProcess } = require('electron');
|
|
const { once } = require('node:events');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
|
|
const child = utilityProcess.fork(fixturePath);
|
|
await once(child, 'spawn');
|
|
|
|
const snapshot = takeHeapSnapshot();
|
|
const found = snapshotContainsRetainingPath(snapshot, ['C++ Persistent roots', 'Electron / UtilityProcess']);
|
|
|
|
child.kill();
|
|
await once(child, 'exit');
|
|
return found;
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js'),
|
|
path.join(import.meta.dirname, 'fixtures/api/utility-process/endless.js')
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should be released from heap snapshot after process exits', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (snapshotHelper: string, fixturePath: string) => {
|
|
const { utilityProcess } = require('electron');
|
|
const { once } = require('node:events');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
let child: any = utilityProcess.fork(fixturePath);
|
|
await once(child, 'exit');
|
|
child = null;
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
|
|
const snapshot = takeHeapSnapshot();
|
|
const found = snapshotContainsRetainingPath(snapshot, ['C++ Persistent roots', 'Electron / UtilityProcess']);
|
|
return !found;
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js'),
|
|
path.join(import.meta.dirname, 'fixtures/api/utility-process/empty.js')
|
|
);
|
|
expect(result, 'UtilityProcess should be released after exit and GC').toBe(true);
|
|
});
|
|
|
|
it('should survive GC when JS reference is dropped but process is still running', async () => {
|
|
const rc = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const exited = once(rc.process, 'exit');
|
|
const result = await rc.remotely(
|
|
async (snapshotHelper: string, fixturePath: string) => {
|
|
const { utilityProcess, app } = require('electron');
|
|
const { once } = require('node:events');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
let child: any = utilityProcess.fork(fixturePath);
|
|
await once(child, 'spawn');
|
|
child = null;
|
|
|
|
// Force GC — the process should still be alive because
|
|
// SelfKeepAlive roots the C++ wrapper.
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
|
|
const snapshot = takeHeapSnapshot();
|
|
const stillAlive = snapshotContainsRetainingPath(snapshot, [
|
|
'C++ Persistent roots',
|
|
'Electron / UtilityProcess'
|
|
]);
|
|
|
|
setTimeout(() => app.quit());
|
|
return stillAlive;
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js'),
|
|
path.join(import.meta.dirname, 'fixtures/api/utility-process/endless.js')
|
|
);
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
expect(result, 'UtilityProcess should survive GC while process is running').toBe(true);
|
|
});
|
|
|
|
it('should not leak when forking multiple processes', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(
|
|
async (fixturePath: string) => {
|
|
const { utilityProcess } = require('electron');
|
|
const { once } = require('node:events');
|
|
const { getCppHeapStatistics } = require('node:v8');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
async function forkAndWait() {
|
|
const child = utilityProcess.fork(fixturePath);
|
|
await once(child, 'exit');
|
|
}
|
|
|
|
async function measure(n: number) {
|
|
for (let i = 0; i < n; i++) {
|
|
await forkAndWait();
|
|
}
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
return getCppHeapStatistics('brief').used_size_bytes;
|
|
}
|
|
|
|
await measure(5);
|
|
const after1 = await measure(10);
|
|
const after2 = await measure(10);
|
|
return { after1, after2 };
|
|
},
|
|
path.join(import.meta.dirname, 'fixtures/api/utility-process/empty.js')
|
|
);
|
|
|
|
const growth = result.after2 - result.after1;
|
|
expect(growth, `C++ heap grew by ${growth} bytes between rounds — likely a leak`).toBeLessThanOrEqual(
|
|
result.after1 * 0.1
|
|
);
|
|
});
|
|
});
|
|
|
|
ifdescribe(process.platform === 'darwin')('inAppPurchase module', () => {
|
|
it('should not allocate on every require', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const [usedBefore, usedAfter] = await remotely(async () => {
|
|
const { inAppPurchase } = require('electron');
|
|
const { getCppHeapStatistics } = require('node:v8');
|
|
console.log(inAppPurchase.canMakePayments());
|
|
const heapStatsBefore = getCppHeapStatistics('brief');
|
|
{
|
|
const { inAppPurchase } = require('electron');
|
|
console.log(inAppPurchase.canMakePayments());
|
|
}
|
|
{
|
|
const { inAppPurchase } = require('electron');
|
|
console.log(inAppPurchase.getReceiptURL());
|
|
}
|
|
const heapStatsAfter = getCppHeapStatistics('brief');
|
|
return [heapStatsBefore.used_size_bytes, heapStatsAfter.used_size_bytes];
|
|
});
|
|
expect(usedBefore).toBe(usedAfter);
|
|
});
|
|
|
|
it('should record as node in heap snapshot', async () => {
|
|
const { remotely } = await startRemoteControlApp();
|
|
const result = await remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { inAppPurchase } = require('electron');
|
|
const { snapshotContainsRetainingPath, takeHeapSnapshot } = require(snapshotHelper);
|
|
console.log(inAppPurchase.canMakePayments());
|
|
return snapshotContainsRetainingPath(takeHeapSnapshot(), ['Electron / InAppPurchase']);
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
ifdescribe(process.platform === 'darwin')('autoUpdater module', () => {
|
|
it('is retained after garbage collection', async () => {
|
|
const rc = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await rc.remotely(async () => {
|
|
const { autoUpdater } = require('electron');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
const wr = new WeakRef(autoUpdater);
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
return {
|
|
retained: wr.deref() !== undefined,
|
|
functional: typeof autoUpdater.getFeedURL() === 'string'
|
|
};
|
|
});
|
|
expect(result.retained, 'autoUpdater should survive GC').toBe(true);
|
|
expect(result.functional, 'autoUpdater should still be functional after GC').toBe(true);
|
|
});
|
|
|
|
it('should record as node in heap snapshot', async () => {
|
|
const rc = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const exited = once(rc.process, 'exit');
|
|
const result = await rc.remotely(
|
|
async (snapshotHelper: string) => {
|
|
const { autoUpdater, app } = require('electron');
|
|
const { countHeapSnapshotNodes, takeHeapSnapshot } = require(snapshotHelper);
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
console.log(autoUpdater.getFeedURL());
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
const { count } = countHeapSnapshotNodes(takeHeapSnapshot(), {
|
|
count: { name: 'Electron / AutoUpdater' }
|
|
});
|
|
const found = count > 0;
|
|
const noDuplicates = count === 1;
|
|
setTimeout(() => app.quit());
|
|
return { found, noDuplicates };
|
|
},
|
|
path.join(import.meta.dirname, 'lib', 'heapsnapshot-helpers.js')
|
|
);
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
expect(result.found, 'AutoUpdater should be in snapshot (held by JS module)').toBe(true);
|
|
expect(result.noDuplicates, 'should have exactly one AutoUpdater instance').toBe(true);
|
|
});
|
|
});
|
|
|
|
ifdescribe(isTestingBindingAvailable())('gin_helper::Promise cppgc', () => {
|
|
it('does not crash on exit with a live PromiseBase', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
const exited = once(rc.process, 'exit');
|
|
await rc.remotely(async () => {
|
|
const { app } = require('electron');
|
|
const testingBinding = (process as any)._linkedBinding('electron_common_testing');
|
|
|
|
// Create a gin_helper::Promise<void> and hold it in a static C++
|
|
// variable. It will outlive the isolate and be destroyed during
|
|
// static destruction — this must not crash.
|
|
testingBinding.holdPromiseForTesting();
|
|
|
|
setTimeout(() => app.quit());
|
|
});
|
|
|
|
const [code] = await exited;
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
it('PromiseHandle should not leak', async () => {
|
|
const { remotely } = await startRemoteControlApp(['--js-flags=--expose-gc']);
|
|
const result = await remotely(async () => {
|
|
const { getCppHeapStatistics } = require('node:v8');
|
|
const testingBinding = (process as any)._linkedBinding('electron_common_testing');
|
|
|
|
// Count PromiseHandles in the C++ heap's per-type statistics.
|
|
// These include any handle not yet reclaimed, garbage too,
|
|
// so the test collects before each count,
|
|
// and the count then reflects the handles live at that point.
|
|
// A heap snapshot shows the same objects,
|
|
// but taking and parsing three snapshots of the main process
|
|
// can overrun the test timeout on slow CI machines.
|
|
const countPromiseHandles = () => {
|
|
const { type_names: typeNames, space_statistics: spaces } = getCppHeapStatistics('detailed');
|
|
let count = 0;
|
|
for (const space of spaces) {
|
|
for (const page of space.page_stats) {
|
|
page.object_statistics.forEach((entry: any, index: number) => {
|
|
if (typeNames[index] === 'Electron / PromiseHandle') count += entry.object_count;
|
|
});
|
|
}
|
|
}
|
|
return count;
|
|
};
|
|
|
|
// Collect from a fresh task without scanning the native stack,
|
|
// so a stale pointer cannot keep a released PromiseHandle alive.
|
|
// This uses V8's gc() rather than v8Util's async collection,
|
|
// whose returned promise would itself leave a PromiseHandle to count.
|
|
// Several rounds also reclaim objects that only become unreachable
|
|
// after an earlier round's weak callbacks have run.
|
|
const gc = async () => {
|
|
for (let i = 0; i < 3; i++) {
|
|
await globalThis.gc!({ type: 'major', execution: 'async' });
|
|
}
|
|
};
|
|
|
|
// The first count is taken before any collection,
|
|
// so start from a collected heap that holds no garbage for gc() to remove.
|
|
await gc();
|
|
testingBinding.holdPromiseForTesting();
|
|
const beforeGC = countPromiseHandles();
|
|
await gc();
|
|
const afterGC = countPromiseHandles();
|
|
|
|
testingBinding.clearHeldPromiseForTesting();
|
|
await gc();
|
|
const afterClear = countPromiseHandles();
|
|
|
|
return { beforeGC, afterGC, afterClear };
|
|
});
|
|
|
|
expect(result.afterGC, 'held PromiseHandle must survive GC').toBeGreaterThanOrEqual(result.beforeGC);
|
|
expect(result.afterClear, 'clearing should release the PromiseHandle').toBeLessThan(result.afterGC);
|
|
});
|
|
});
|
|
|
|
describe('webFrame module', () => {
|
|
itremote('does not leak WebFrameRenderer wrappers', async () => {
|
|
const { webFrame } = require('electron');
|
|
const v8Util = (process as any)._linkedBinding('electron_common_v8_util');
|
|
|
|
const refs: WeakRef<object>[] = (() => {
|
|
const out: WeakRef<object>[] = [];
|
|
for (let i = 0; i < 50; i++) {
|
|
out.push(new WeakRef(webFrame.top as unknown as object));
|
|
}
|
|
return out;
|
|
})();
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
v8Util.requestGarbageCollectionForTesting();
|
|
}
|
|
|
|
const survivors = refs.filter((ref) => ref.deref() !== undefined).length;
|
|
expect(survivors).toBe(0);
|
|
});
|
|
});
|
|
});
|