Bug report: Zotero Chrome extension possible memory leak
## Issue: Memory leak — "Zotero Subframe" grows to 800MB+ RAM
### Environment
- **Extension:** Zotero Connector (Chrome, MV3)
- **Observed on:** Chromium-based browsers
### Symptoms
In Chrome's Task Manager (Shift+Esc), a process labeled "Subframe: Zotero Connector" (or similar — the offscreen document's sandbox iframe) grows over time to **1GB+ RAM**, while the "Extension: Zotero Connector" service worker process stays at a normal ~60MB.
### Root Cause Analysis
The memory leak is caused by **five related bugs** in the MV3 offscreen document and iframe lifecycle management:
---
#### Bug 1 (Primary): Translate instances accumulate in the offscreen sandbox with no reliable cleanup
**File:** `src/browserExt/offscreen/offscreenTranslate.js` line 209
**File:** `src/browserExt/background/offscreenManager.js` line 63, 95
The `onTranslateCleanup` method has a **JavaScript type mismatch** — `tabIds` are numbers but `Object.keys()` returns strings:
```javascript
// offscreenManager.js line 95 — sends NUMBER tabIds
let cleanedUpTabIds = await this.sendMessage('translateCleanup',
tabs.map(tab => tab.id)); // → [42, 43, 44] (numbers)
// offscreenTranslate.js lines 209-212 — compares numbers against STRING keys
onTranslateCleanup(tabIds) {
let deadTranslates = new Set(Object.keys(this.translateInstances)); // → Set{"42","43"}
for (let tabId of tabIds) {
deadTranslates.delete(tabId); // delete(42) ≠ "42" — NEVER matches!
}
// ALL translate instances are treated as "dead" and deleted
}
```
The bug *accidentally* works by cleaning **everything** every 15 minutes. But the cleanup timer lives in the **service worker** (`setInterval` at offscreenManager.js line 63). When Chrome aggressively terminates the idle service worker, this interval dies **permanently** while the offscreen document persists across SW restarts. Translate instances then accumulate with no cleanup until the browser is restarted.
Each translate instance holds a full parsed `DOMParser` tree of the page HTML, which can be 10-50MB for complex pages. After browsing 20-30 pages, the sandbox iframe can easily bloat to 800MB+.
**Also** `onTranslateCleanup` causes the **[opposite problem on SW restart]** — when the service worker restarts and the cleanup fires, it destroys ALL translate instances including those for still-open tabs, causing unnecessary re-parsing.
---
#### Bug 2: `ZoteroFrame.remove()` is commented out, leaking iframes on every large payload
**File:** `src/browserExt/messaging_inject.js` lines 159-165
```javascript
this._sendViaIframeServiceWorkerPort = async function(messageName, args) {
// ...
const frame = new ZoteroFrame({
src: Zotero.getExtensionURL("chromeMessageIframe/messageIframe.html"),
}, { display: "none" }, {});
await frame.init();
let response = await frame.sendMessage('sendToBackground', [messageName, args])
// frame.remove(); // ← NEVER CALLED — iframe + MessageChannel leak!
return response;
}
```
Every time a large payload is sent (SingleFile snapshots, HTML attachment saves), a new hidden iframe with a `MessageChannel` is injected into the page DOM but **never removed**. Over a browsing session, dozens of dead iframes accumulate.
---
#### Bug 3: `ZoteroFrame.remove()` is broken even when called
**File:** `src/browserExt/zoteroFrame.js` lines 86-88
```javascript
remove() {
document.body?.removeChild(this._frame); // WRONG parent!
}
```
`this._frame` is a child of the **shadow root** of `this.parentDiv`, not of `document.body`. Even when `remove()` is called, it throws a `NotFoundError` and `parentDiv` (with its shadow DOM) remains attached to the page.
The correct removal is:
```javascript
remove() {
this.parentDiv?.remove();
}
```
### Environment
- **Extension:** Zotero Connector (Chrome, MV3)
- **Observed on:** Chromium-based browsers
### Symptoms
In Chrome's Task Manager (Shift+Esc), a process labeled "Subframe: Zotero Connector" (or similar — the offscreen document's sandbox iframe) grows over time to **1GB+ RAM**, while the "Extension: Zotero Connector" service worker process stays at a normal ~60MB.
### Root Cause Analysis
The memory leak is caused by **five related bugs** in the MV3 offscreen document and iframe lifecycle management:
---
#### Bug 1 (Primary): Translate instances accumulate in the offscreen sandbox with no reliable cleanup
**File:** `src/browserExt/offscreen/offscreenTranslate.js` line 209
**File:** `src/browserExt/background/offscreenManager.js` line 63, 95
The `onTranslateCleanup` method has a **JavaScript type mismatch** — `tabIds` are numbers but `Object.keys()` returns strings:
```javascript
// offscreenManager.js line 95 — sends NUMBER tabIds
let cleanedUpTabIds = await this.sendMessage('translateCleanup',
tabs.map(tab => tab.id)); // → [42, 43, 44] (numbers)
// offscreenTranslate.js lines 209-212 — compares numbers against STRING keys
onTranslateCleanup(tabIds) {
let deadTranslates = new Set(Object.keys(this.translateInstances)); // → Set{"42","43"}
for (let tabId of tabIds) {
deadTranslates.delete(tabId); // delete(42) ≠ "42" — NEVER matches!
}
// ALL translate instances are treated as "dead" and deleted
}
```
The bug *accidentally* works by cleaning **everything** every 15 minutes. But the cleanup timer lives in the **service worker** (`setInterval` at offscreenManager.js line 63). When Chrome aggressively terminates the idle service worker, this interval dies **permanently** while the offscreen document persists across SW restarts. Translate instances then accumulate with no cleanup until the browser is restarted.
Each translate instance holds a full parsed `DOMParser` tree of the page HTML, which can be 10-50MB for complex pages. After browsing 20-30 pages, the sandbox iframe can easily bloat to 800MB+.
**Also** `onTranslateCleanup` causes the **[opposite problem on SW restart]** — when the service worker restarts and the cleanup fires, it destroys ALL translate instances including those for still-open tabs, causing unnecessary re-parsing.
---
#### Bug 2: `ZoteroFrame.remove()` is commented out, leaking iframes on every large payload
**File:** `src/browserExt/messaging_inject.js` lines 159-165
```javascript
this._sendViaIframeServiceWorkerPort = async function(messageName, args) {
// ...
const frame = new ZoteroFrame({
src: Zotero.getExtensionURL("chromeMessageIframe/messageIframe.html"),
}, { display: "none" }, {});
await frame.init();
let response = await frame.sendMessage('sendToBackground', [messageName, args])
// frame.remove(); // ← NEVER CALLED — iframe + MessageChannel leak!
return response;
}
```
Every time a large payload is sent (SingleFile snapshots, HTML attachment saves), a new hidden iframe with a `MessageChannel` is injected into the page DOM but **never removed**. Over a browsing session, dozens of dead iframes accumulate.
---
#### Bug 3: `ZoteroFrame.remove()` is broken even when called
**File:** `src/browserExt/zoteroFrame.js` lines 86-88
```javascript
remove() {
document.body?.removeChild(this._frame); // WRONG parent!
}
```
`this._frame` is a child of the **shadow root** of `this.parentDiv`, not of `document.body`. Even when `remove()` is called, it throws a `NotFoundError` and `parentDiv` (with its shadow DOM) remains attached to the page.
The correct removal is:
```javascript
remove() {
this.parentDiv?.remove();
}
```
Upgrade Storage