fix(matrix): prune finished fake-indexeddb transactions to prevent OOM (#94942)

fake-indexeddb@6.2.5 retains finished transactions in raw.transactions
array indefinitely. For Matrix E2EE crypto stores, this causes unbounded
heap growth and eventual OOM crashes.

Add a transaction pruner that patches IDBDatabase.prototype.transaction
to automatically remove finished transactions for Matrix crypto databases
(::matrix-sdk-crypto and ::matrix-sdk-crypto-meta suffixes).

Fixes #90455
This commit is contained in:
Dirk
2026-06-23 02:06:55 +08:00
committed by GitHub
parent e3a496a29a
commit 96e49705a6
3 changed files with 248 additions and 0 deletions

View File

@@ -1,5 +1,8 @@
// Matrix plugin module implements crypto runtime behavior.
import "fake-indexeddb/auto";
import { installFakeIndexedDbTransactionPruner } from "./fake-indexeddb-prune.js";
installFakeIndexedDbTransactionPruner();
export { MatrixCryptoBootstrapper } from "./crypto-bootstrap.js";
export type { MatrixCryptoBootstrapResult } from "./crypto-bootstrap.js";

View File

@@ -0,0 +1,126 @@
// Matrix tests cover fake-indexeddb transaction pruning for crypto stores.
import "fake-indexeddb/auto";
import { afterEach, describe, expect, it } from "vitest";
import {
installFakeIndexedDbTransactionPruner,
pruneFinishedFakeIndexedDbTransactions,
} from "./fake-indexeddb-prune.js";
function openDatabase(name: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, 1);
request.addEventListener("upgradeneeded", () => {
request.result.createObjectStore("sessions", { keyPath: "key" });
});
request.addEventListener("success", () => resolve(request.result), { once: true });
request.addEventListener(
"error",
() => reject(request.error ?? new Error("database open failed")),
{
once: true,
},
);
});
}
function deleteDatabase(name: string): Promise<void> {
return new Promise((resolve) => {
const request = indexedDB.deleteDatabase(name);
request.addEventListener("success", () => resolve(), { once: true });
request.addEventListener("error", () => resolve(), { once: true });
request.addEventListener("blocked", () => resolve(), { once: true });
});
}
function putRecord(db: IDBDatabase, key: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction("sessions", "readwrite");
transaction.objectStore("sessions").put({ key, value: "payload" });
transaction.addEventListener("complete", () => resolve(), { once: true });
transaction.addEventListener(
"error",
() => reject(transaction.error ?? new Error("transaction failed")),
{
once: true,
},
);
transaction.addEventListener(
"abort",
() => reject(transaction.error ?? new Error("transaction aborted")),
{
once: true,
},
);
});
}
function rawTransactions(db: IDBDatabase): Array<{ _state?: string }> {
return (
(db as unknown as { _rawDatabase?: { transactions?: Array<{ _state?: string }> } })[
"_rawDatabase"
]?.transactions ?? []
);
}
describe("Matrix fake-indexeddb transaction pruning", () => {
const databaseNames = new Set<string>();
afterEach(async () => {
for (const name of databaseNames) {
await deleteDatabase(name);
}
databaseNames.clear();
});
it("prunes finished transactions for Matrix crypto databases", async () => {
installFakeIndexedDbTransactionPruner();
const databaseName = `openclaw-matrix-prune-test-${Date.now()}::matrix-sdk-crypto`;
databaseNames.add(databaseName);
const db = await openDatabase(databaseName);
for (let i = 0; i < 5; i += 1) {
await putRecord(db, `key-${i}`);
}
expect(rawTransactions(db)).toHaveLength(0);
db.close();
});
it("prunes finished transactions for Matrix crypto metadata databases", async () => {
installFakeIndexedDbTransactionPruner();
const databaseName = `openclaw-matrix-meta-prune-test-${Date.now()}::matrix-sdk-crypto-meta`;
databaseNames.add(databaseName);
const db = await openDatabase(databaseName);
for (let i = 0; i < 5; i += 1) {
await putRecord(db, `key-${i}`);
}
expect(rawTransactions(db)).toHaveLength(0);
db.close();
});
it("does not prune unrelated fake-indexeddb databases", async () => {
installFakeIndexedDbTransactionPruner();
const databaseName = `openclaw-matrix-unrelated-prune-test-${Date.now()}`;
databaseNames.add(databaseName);
const db = await openDatabase(databaseName);
await putRecord(db, "key-1");
expect(
rawTransactions(db).filter((transaction) => transaction["_state"] === "finished").length,
).toBeGreaterThan(0);
db.close();
});
it("keeps active transactions when pruning the raw transaction queue", () => {
const rawDatabase = {
name: "openclaw-matrix-direct-prune-test::matrix-sdk-crypto",
transactions: [{ _state: "finished" }, { _state: "active" }, { _state: "inactive" }],
};
expect(pruneFinishedFakeIndexedDbTransactions(rawDatabase)).toBe(1);
expect(rawDatabase.transactions).toEqual([{ _state: "active" }, { _state: "inactive" }]);
});
});

View File

@@ -0,0 +1,119 @@
// Matrix SDK helper mitigates fake-indexeddb finished-transaction retention.
const MATRIX_CRYPTO_DATABASE_SUFFIXES = [
"::matrix-sdk-crypto",
"::matrix-sdk-crypto-meta",
] as const;
const PRUNER_INSTALLED = Symbol.for("openclaw.matrix.fakeIndexedDbTransactionPruner");
type FakeIndexedDbTransaction = {
_state?: string;
addEventListener?: (type: "complete" | "abort", listener: () => void) => void;
db?: FakeIndexedDbDatabaseConnection;
};
type FakeIndexedDbRawDatabase = {
name?: string;
transactions?: FakeIndexedDbTransaction[];
};
type FakeIndexedDbDatabaseConnection = {
_rawDatabase?: FakeIndexedDbRawDatabase;
};
type FakeIndexedDbDatabasePrototype = FakeIndexedDbDatabaseConnection & {
transaction?: IDBDatabase["transaction"];
[PRUNER_INSTALLED]?: true;
};
type GlobalWithFakeIndexedDb = typeof globalThis & {
IDBDatabase?: {
prototype?: FakeIndexedDbDatabasePrototype;
};
};
function getRawDatabase(value: unknown): FakeIndexedDbRawDatabase | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const rawDatabase = (value as FakeIndexedDbDatabaseConnection)["_rawDatabase"];
if (!rawDatabase || typeof rawDatabase !== "object") {
return undefined;
}
return rawDatabase;
}
function isMatrixCryptoDatabase(
rawDatabase: FakeIndexedDbRawDatabase | undefined,
): rawDatabase is FakeIndexedDbRawDatabase & {
transactions: FakeIndexedDbTransaction[];
} {
if (!rawDatabase || !Array.isArray(rawDatabase.transactions)) {
return false;
}
const databaseName = rawDatabase.name;
return (
typeof databaseName === "string" &&
MATRIX_CRYPTO_DATABASE_SUFFIXES.some((suffix) => databaseName.endsWith(suffix))
);
}
export function pruneFinishedFakeIndexedDbTransactions(rawDatabase: unknown): number {
const matrixRawDatabase = rawDatabase as FakeIndexedDbRawDatabase | undefined;
if (!isMatrixCryptoDatabase(matrixRawDatabase)) {
return 0;
}
const transactions = matrixRawDatabase.transactions;
const activeTransactions = transactions.filter(
(transaction) => transaction?.["_state"] !== "finished",
);
const removed = transactions.length - activeTransactions.length;
if (removed > 0) {
transactions.splice(0, transactions.length, ...activeTransactions);
}
return removed;
}
export function installFakeIndexedDbTransactionPruner(): void {
const globalObject = globalThis as GlobalWithFakeIndexedDb;
const databasePrototype = globalObject.IDBDatabase?.prototype;
const originalTransaction = databasePrototype?.transaction;
if (
!databasePrototype ||
typeof originalTransaction !== "function" ||
databasePrototype[PRUNER_INSTALLED]
) {
return;
}
Object.defineProperty(databasePrototype, PRUNER_INSTALLED, {
configurable: false,
enumerable: false,
value: true,
});
const patchedTransaction = function patchedMatrixFakeIndexedDbTransaction(
this: IDBDatabase & FakeIndexedDbDatabaseConnection,
...args: Parameters<IDBDatabase["transaction"]>
): ReturnType<IDBDatabase["transaction"]> {
pruneFinishedFakeIndexedDbTransactions(getRawDatabase(this));
const transaction = originalTransaction.apply(this, args) as IDBTransaction &
FakeIndexedDbTransaction;
const rawDatabase = getRawDatabase(transaction?.db) ?? getRawDatabase(this);
if (
isMatrixCryptoDatabase(rawDatabase) &&
typeof transaction?.addEventListener === "function"
) {
const prune = (): void => {
pruneFinishedFakeIndexedDbTransactions(rawDatabase);
};
transaction.addEventListener("complete", prune);
transaction.addEventListener("abort", prune);
}
return transaction;
} as IDBDatabase["transaction"];
databasePrototype.transaction = patchedTransaction;
}