mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 02:22:46 +00:00
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:
@@ -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";
|
||||
|
||||
126
extensions/matrix/src/matrix/sdk/fake-indexeddb-prune.test.ts
Normal file
126
extensions/matrix/src/matrix/sdk/fake-indexeddb-prune.test.ts
Normal 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" }]);
|
||||
});
|
||||
});
|
||||
119
extensions/matrix/src/matrix/sdk/fake-indexeddb-prune.ts
Normal file
119
extensions/matrix/src/matrix/sdk/fake-indexeddb-prune.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user