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();
}
```
Sign In or Register to comment.