Skip to content

[Bug]: Matrix E2EE heap grows unbounded → OOM — fake-indexeddb never clears finished transactions (@openclaw/matrix) #90455

Description

@yar-sh

Bug type

Crash (process/app exits or hangs)

Beta release blocker

No

Summary

With the @openclaw/matrix E2EE plugin, the gateway heap grows without bound and OOM-crashes on a clock, because fake-indexeddb (the Node IndexedDB shim backing the Matrix rust-crypto store) appends every transaction to an internal array and never removes finished ones.

Steps to reproduce

A. Isolated, no Matrix — against the exact shipped fake-indexeddb@6.2.5:

import "fake-indexeddb/auto";
const db = await new Promise((res, rej) => {
  const r = indexedDB.open("leaktest", 1);
  r.onupgradeneeded = () => r.result.createObjectStore("s", { keyPath: "k" });
  r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
});
const raw = db._rawDatabase;
for (let i = 1; i <= 120000; i++) {
  await new Promise((res, rej) => {
    const t = db.transaction("s", "readwrite");
    t.objectStore("s").put({ k: i % 50, v: "x".repeat(200) });
    t.oncomplete = res; t.onerror = () => rej(t.error);
  });
  if (i % 30000 === 0) { global.gc?.(); console.log(i, "txns_array=", raw.transactions.length, "heapMB=", (process.memoryUsage().heapUsed/1048576|0)); }
}
// run: node --expose-gc repro.mjs

raw.transactions.length tracks i (never shrinks, survives GC); only ~50 records are actually stored.

B. Live gateway — run @openclaw/matrix with E2EE + several accounts in shared rooms; over the inspector, poll globalThis.indexedDB._databases and sum db.transactions.length per store. The openclaw-matrix-<account>::matrix-sdk-crypto stores climb monotonically with no inbound messages and no agent/LLM turns.

Expected behavior

A continuously-running Matrix client should not OOM from crypto-store bookkeeping. Per the IndexedDB spec a finished transaction is not retained, and every read inside fake-indexeddb already filters on _state !== "finished" — so finished transactions should not stay resident. With the one-line prune below applied, repro A holds transactions.length at 0 and heap flat (~4 MB) through 120k transactions, and the live stores hold flat at ~0.

Actual behavior

The Database.transactions array grows 1:1 with transactions ever created and is never pruned, so the heap climbs linearly to the v8 old-space ceiling and the process dies with FATAL ERROR: Ineffective mark-compacts near heap limit. Repro A:

            transactions.length    heapUsedMB
  60,000  →  60,001                 204
  90,000  →  90,001                 303
 120,000  → 120,001                 403

Live 11-account gateway: per-store totals climbed ~733 → ~1164 in 80s (+~300 every ~30s, in lockstep across accounts = the /sync fan-out). Heap grew ~95 MB/h → OOM ~every 21h on a 2 GiB heap, ~43h on 4 GiB.

OpenClaw version

2026.5.12 (root cause also present on main / v2026.5.28 by source inspection — see Additional information)

Operating system

Debian GNU/Linux 12 (bookworm) in Docker; Node v24.14.0; host kernel Linux 6.12.69 x86_64

Install method

Docker — ghcr.io/openclaw/openclaw@sha256:e2482a66682de6f540dcfd9921e410c23fd060dcd441382ff952247ee911a672, @openclaw/matrix plugin installed at runtime

Model

N/A — independent of model; reproduced with zero LLM turns (the leak is pure Matrix sync/crypto traffic)

Provider / routing chain

N/A — no inference path involved; the growth is Matrix protocol/sync/crypto only

Logs, screenshots, and evidence

# Heap snapshot at OOM — dominated by fake-indexeddb (FDB-family) objects:
#   FDBTransaction     ~459,469 instances   77 MB
#   FDBObjectStore     ~459,645 instances   35 MB
#   FakeDOMStringList  ~932,611 instances   28 MB
#   FDB-family ≈ 9% of total node population vs ~0.1% for session-named nodes
#
# Live inspector poll (11 accounts, no human input, no agent turns):
#   t+0s    total  733
#   t+25s   total  873   (+140)
#   t+50s   total 1188   (+315)
#   t+80s   total 1308
#   every growing DB is named openclaw-matrix-<account>::matrix-sdk-crypto
#
# After the prune (same gateway, same traffic): per-store count flat at ~0, heap steady.

Impact and severity

  • Affected: any deployment using the @openclaw/matrix plugin with E2EE; worse with multiple accounts in shared rooms (every protocol event is crypto-processed by all N clients → rate scales with account × sync).
  • Severity: High — process OOM-crash; takes the gateway (all channels) down. Auto-restart masks it as a periodic blip.
  • Frequency: Always, on a clock — ~21h (2 GiB) / ~43h (4 GiB) here; purely time/traffic driven.
  • Consequence: Recurring gateway outage + lost in-flight sessions, with no inbound traffic or LLM cost — pure protocol churn.

Additional information

Distinct from #89315. This surfaced while investigating #89315, but that reporter runs feishu/mattermost and confirmed no Matrix / FDB paths — so filing the matrix-specific cause separately.

Mechanism (every hop is a source line):

  1. crypto-runtime.ts:1 import "fake-indexeddb/auto" → sets globalThis.indexedDB.
  2. sdk.ts:788 client.initRustCrypto({ cryptoDatabasePrefix }) (non-null prefix ⇒ persistent IndexedDB store, needed for E2EE key persistence).
  3. matrix-js-sdk rust-crypto/index.ts:108-115 StoreHandle.open(storePrefix, …).
  4. matrix-rust-sdk wasm matrix-sdk-crypto-wasm store.rs:88 IndexeddbCryptoStore::open_with_name(...).
  5. matrix-rust-sdk crypto_store/mod.rs:1768 Database::open("{prefix}::matrix-sdk-crypto[-meta]") (the DB name in the snapshot).
  6. indexed_db_futuresweb-sys → the wasm-bindgen glue __wbg_indexedDB_* in @matrix-org/matrix-sdk-crypto-wasm (body const ret = arg0.indexedDB) → reads globalThis.indexedDB = the fake-indexeddb shim → FDBFactory.open → the leaking Database.

The leak, in fake-indexeddb v6.2.5 (latest published): pushed at FDBDatabase.ts:246, marked finished at FDBTransaction.ts:309, but processTransactions() only filters into temp arrays to schedule the next run — nothing ever removes finished transactions. db.close() doesn't help: closeConnection() filters connections, never transactions.

Reopening the store doesn't reset it either: FDBFactory.open reuses the cached Database from its process-wide _databases map (databases.get(name); a new Database is created only when absent), so every reopen makes a fresh connection over the same backing Database and its growing array. The only thing that drops a Database is FDBFactory.deleteDatabase(name)databases.delete(name), which the crypto store never calls (it would destroy the persistent E2EE keys). So the array lives for the whole process. Corroborated by the snapshot: ~13.5k FDBDatabase connection objects over ~11 underlying Databases — thousands of reopens, one persistent transactions array each.

Still on latest: v2026.5.28 extensions/matrix/package.json still pins "fake-indexeddb": "6.2.5" (matrix-js-sdk bumped to 41.6.0, store backend unchanged). Independent of the SQLite migration (#81402): that moves snapshot persistence, the live crypto store stays fake-indexeddb.

Verified workaround — one-line prune at the top of processTransactions() (spec-correct):

// fake-indexeddb build/{esm,cjs}/lib/Database.js, first line of processTransactions():
this.transactions = this.transactions.filter(t => t._state !== "finished");

Repro A then stays at 0 (heap flat ~4 MB); the live gateway's per-store count goes flat. Note: @openclaw/matrix + its fake-indexeddb install at runtime into ~/.openclaw/npm/node_modules/fake-indexeddb, not the image node_modules — patching the image / dist bundle has no effect; the live module loads from the config-volume copy.

Possible fixes:

  • Prune in fake-indexeddb's processTransactions() (as above) — continuous, keeps the array at ~0, uses the lib's own field; spec-correct. Cleanest, but patches the dependency.
  • Upstream that prune into fake-indexeddb — the array never freeing finished transactions is arguably a library bug when it's used as a long-lived store.
  • Consumer-side prune in the existing 60s persist cycle — no dependency patch: at the end of each persistIdbToDisk tick, drop finished transactions from each open database, e.g.
    for (const [, database] of fakeIndexedDB._databases) {
      database.transactions = database.transactions.filter(t => t._state !== "finished");
    }
    Bounds the array to ~60s of churn rather than ~0, and reaches into the shim's internals (_databases / Database.transactions), but requires no fork.
  • Back the rust-crypto store with something other than a test-oriented shim for production (fake-indexeddb appears to be used only by the matrix extension).

Happy to share the full snapshot analysis + inspector poller.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:crash-loopCrash, hang, restart loop, or process-level availability failure.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions