Merge pull request #259 from vector-im/bwindels/fix-258

Await bogus idb request on webkit browsers to prevent transaction closing early
This commit is contained in:
Bruno Windels 2021-03-04 18:56:51 +00:00 committed by GitHub
commit 4b075e582e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 179 additions and 48 deletions

View file

@ -23,6 +23,8 @@ without waiting for any *micro*tasks. See comments about Safari at https://githu
Another failure mode perceived in Hydrogen on Safari is that when the (readonly) prepareTxn in sync wasn't awaited to be completed before opening and using the syncTxn. Another failure mode perceived in Hydrogen on Safari is that when the (readonly) prepareTxn in sync wasn't awaited to be completed before opening and using the syncTxn.
I haven't found any documentation online about this at all. Awaiting prepareTxn.complete() fixed the issue below. It's strange though the put does not fail. I haven't found any documentation online about this at all. Awaiting prepareTxn.complete() fixed the issue below. It's strange though the put does not fail.
## Diagnose of problem
What is happening below is: What is happening below is:
- in the sync loop: - in the sync loop:
- we first open a readonly txn on inboundGroupSessions, which we don't use in the example below - we first open a readonly txn on inboundGroupSessions, which we don't use in the example below

View file

@ -0,0 +1,71 @@
<html>
<head><meta charset="utf-8"></head>
<body>
<script type="text/javascript">
const log = (...params) => {
document.write(params.join(" ")+"<br>");
};
function reqAsPromise(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = (err) => reject(err);
});
}
function txnAsPromise(txn) {
return new Promise((resolve, reject) => {
txn.addEventListener("complete", resolve);
txn.addEventListener("abort", reject);
});
}
function openDatabase(name, createObjectStore, version) {
const req = indexedDB.open(name, version);
req.onupgradeneeded = (ev) => {
const db = ev.target.result;
const txn = ev.target.transaction;
const oldVersion = ev.oldVersion;
createObjectStore(db, txn, oldVersion, version);
};
return reqAsPromise(req);
}
async function detectWebkitEarlyCloseTxnBug() {
const dbName = "webkit_test_inactive_txn_" + Math.random() * Number.MAX_SAFE_INTEGER;
try {
const db = await openDatabase(dbName, db => {
db.createObjectStore("test", {keyPath: "key"});
}, 1);
const readTxn = db.transaction(["test"], "readonly");
await reqAsPromise(readTxn.objectStore("test").get("somekey"));
// schedule a macro task in between the two txns
await new Promise(r => setTimeout(r, 0));
const writeTxn = db.transaction(["test"], "readwrite");
await Promise.resolve();
writeTxn.objectStore("test").add({key: "somekey", value: "foo"});
await txnAsPromise(writeTxn);
} catch (err) {
if (err.name === "TransactionInactiveError") {
return true;
}
} finally {
try {
indexedDB.deleteDatabase(dbName);
} catch (err) {}
}
return false;
}
(async () => {
if (await detectWebkitEarlyCloseTxnBug()) {
log("the test failed, your browser seems to have the bug");
} else {
log("the test succeeded, your browser seems fine");
}
})();
</script>
</body>
</html>

View file

@ -185,13 +185,13 @@ export class Session {
} }
const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm); const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm);
// and create session backup, which needs to read from accountData // and create session backup, which needs to read from accountData
const readTxn = this._storage.readTxn([ const readTxn = await this._storage.readTxn([
this._storage.storeNames.accountData, this._storage.storeNames.accountData,
]); ]);
await this._createSessionBackup(key, readTxn); await this._createSessionBackup(key, readTxn);
// only after having read a secret, write the key // only after having read a secret, write the key
// as we only find out if it was good if the MAC verification succeeds // as we only find out if it was good if the MAC verification succeeds
const writeTxn = this._storage.readWriteTxn([ const writeTxn = await this._storage.readWriteTxn([
this._storage.storeNames.session, this._storage.storeNames.session,
]); ]);
try { try {
@ -249,7 +249,7 @@ export class Session {
/** @internal */ /** @internal */
async load(log) { async load(log) {
const txn = this._storage.readTxn([ const txn = await this._storage.readTxn([
this._storage.storeNames.session, this._storage.storeNames.session,
this._storage.storeNames.roomSummary, this._storage.storeNames.roomSummary,
this._storage.storeNames.roomMembers, this._storage.storeNames.roomMembers,
@ -301,7 +301,7 @@ export class Session {
async start(lastVersionResponse, log) { async start(lastVersionResponse, log) {
if (lastVersionResponse) { if (lastVersionResponse) {
// store /versions response // store /versions response
const txn = this._storage.readWriteTxn([ const txn = await this._storage.readWriteTxn([
this._storage.storeNames.session this._storage.storeNames.session
]); ]);
txn.session.set("serverVersions", lastVersionResponse); txn.session.set("serverVersions", lastVersionResponse);
@ -310,7 +310,7 @@ export class Session {
} }
// enable session backup, this requests the latest backup version // enable session backup, this requests the latest backup version
if (!this._sessionBackup) { if (!this._sessionBackup) {
const txn = this._storage.readTxn([ const txn = await this._storage.readTxn([
this._storage.storeNames.session, this._storage.storeNames.session,
this._storage.storeNames.accountData, this._storage.storeNames.accountData,
]); ]);
@ -323,7 +323,7 @@ export class Session {
this._hasSecretStorageKey.set(!!ssssKey); this._hasSecretStorageKey.set(!!ssssKey);
} }
// restore unfinished operations, like sending out room keys // restore unfinished operations, like sending out room keys
const opsTxn = this._storage.readWriteTxn([ const opsTxn = await this._storage.readWriteTxn([
this._storage.storeNames.operations this._storage.storeNames.operations
]); ]);
const operations = await opsTxn.operations.getAll(); const operations = await opsTxn.operations.getAll();

View file

@ -201,7 +201,7 @@ export class Sync {
return rs.room.afterPrepareSync(rs.preparation, log); return rs.room.afterPrepareSync(rs.preparation, log);
}))); })));
await log.wrap("write", async log => { await log.wrap("write", async log => {
const syncTxn = this._openSyncTxn(); const syncTxn = await this._openSyncTxn();
try { try {
sessionState.changes = await log.wrap("session", log => this._session.writeSync( sessionState.changes = await log.wrap("session", log => this._session.writeSync(
response, syncFilterId, sessionState.preparation, syncTxn, log)); response, syncFilterId, sessionState.preparation, syncTxn, log));
@ -253,7 +253,7 @@ export class Sync {
} }
async _prepareSessionAndRooms(sessionState, roomStates, response, log) { async _prepareSessionAndRooms(sessionState, roomStates, response, log) {
const prepareTxn = this._openPrepareSyncTxn(); const prepareTxn = await this._openPrepareSyncTxn();
sessionState.preparation = await log.wrap("session", log => this._session.prepareSync( sessionState.preparation = await log.wrap("session", log => this._session.prepareSync(
response, sessionState.lock, prepareTxn, log)); response, sessionState.lock, prepareTxn, log));

View file

@ -45,7 +45,7 @@ export class Account {
} }
const pickledAccount = account.pickle(pickleKey); const pickledAccount = account.pickle(pickleKey);
const areDeviceKeysUploaded = false; const areDeviceKeysUploaded = false;
const txn = storage.readWriteTxn([ const txn = await storage.readWriteTxn([
storage.storeNames.session storage.storeNames.session
]); ]);
try { try {
@ -225,7 +225,7 @@ export class Account {
} }
async _updateSessionStorage(storage, callback) { async _updateSessionStorage(storage, callback) {
const txn = storage.readWriteTxn([ const txn = await storage.readWriteTxn([
storage.storeNames.session storage.storeNames.session
]); ]);
try { try {

View file

@ -75,7 +75,7 @@ export class DeviceTracker {
} }
const memberList = await room.loadMemberList(log); const memberList = await room.loadMemberList(log);
try { try {
const txn = this._storage.readWriteTxn([ const txn = await this._storage.readWriteTxn([
this._storage.storeNames.roomSummary, this._storage.storeNames.roomSummary,
this._storage.storeNames.userIdentities, this._storage.storeNames.userIdentities,
]); ]);
@ -157,7 +157,7 @@ export class DeviceTracker {
}, {log}).response(); }, {log}).response();
const verifiedKeysPerUser = log.wrap("verify", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log)); const verifiedKeysPerUser = log.wrap("verify", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log));
const txn = this._storage.readWriteTxn([ const txn = await this._storage.readWriteTxn([
this._storage.storeNames.userIdentities, this._storage.storeNames.userIdentities,
this._storage.storeNames.deviceIdentities, this._storage.storeNames.deviceIdentities,
]); ]);
@ -271,7 +271,7 @@ export class DeviceTracker {
* @return {[type]} [description] * @return {[type]} [description]
*/ */
async devicesForTrackedRoom(roomId, hsApi, log) { async devicesForTrackedRoom(roomId, hsApi, log) {
const txn = this._storage.readTxn([ const txn = await this._storage.readTxn([
this._storage.storeNames.roomMembers, this._storage.storeNames.roomMembers,
this._storage.storeNames.userIdentities, this._storage.storeNames.userIdentities,
]); ]);
@ -287,7 +287,7 @@ export class DeviceTracker {
} }
async devicesForRoomMembers(roomId, userIds, hsApi, log) { async devicesForRoomMembers(roomId, userIds, hsApi, log) {
const txn = this._storage.readTxn([ const txn = await this._storage.readTxn([
this._storage.storeNames.userIdentities, this._storage.storeNames.userIdentities,
]); ]);
return await this._devicesForUserIds(roomId, userIds, txn, hsApi, log); return await this._devicesForUserIds(roomId, userIds, txn, hsApi, log);
@ -319,7 +319,7 @@ export class DeviceTracker {
queriedDevices = await this._queryKeys(outdatedIdentities.map(i => i.userId), hsApi, log); queriedDevices = await this._queryKeys(outdatedIdentities.map(i => i.userId), hsApi, log);
} }
const deviceTxn = this._storage.readTxn([ const deviceTxn = await this._storage.readTxn([
this._storage.storeNames.deviceIdentities, this._storage.storeNames.deviceIdentities,
]); ]);
const devicesPerUser = await Promise.all(upToDateIdentities.map(identity => { const devicesPerUser = await Promise.all(upToDateIdentities.map(identity => {

View file

@ -59,7 +59,7 @@ export class RoomEncryption {
const events = entries.filter(e => e.isEncrypted && !e.isDecrypted && e.event).map(e => e.event); const events = entries.filter(e => e.isEncrypted && !e.isDecrypted && e.event).map(e => e.event);
const eventsBySession = groupEventsBySession(events); const eventsBySession = groupEventsBySession(events);
const groups = Array.from(eventsBySession.values()); const groups = Array.from(eventsBySession.values());
const txn = this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]); const txn = await this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]);
const hasSessions = await Promise.all(groups.map(async group => { const hasSessions = await Promise.all(groups.map(async group => {
return this._megolmDecryption.hasSession(this._room.id, group.senderKey, group.sessionId, txn); return this._megolmDecryption.hasSession(this._room.id, group.senderKey, group.sessionId, txn);
})); }));
@ -164,7 +164,7 @@ export class RoomEncryption {
return; return;
} }
// now check which sessions have been received already // now check which sessions have been received already
const txn = this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]); const txn = await this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]);
await Promise.all(Array.from(eventsBySession).map(async ([key, group]) => { await Promise.all(Array.from(eventsBySession).map(async ([key, group]) => {
if (await this._megolmDecryption.hasSession(this._room.id, group.senderKey, group.sessionId, txn)) { if (await this._megolmDecryption.hasSession(this._room.id, group.senderKey, group.sessionId, txn)) {
eventsBySession.delete(key); eventsBySession.delete(key);
@ -211,7 +211,7 @@ export class RoomEncryption {
if (roomKey) { if (roomKey) {
let keyIsBestOne = false; let keyIsBestOne = false;
try { try {
const txn = this._storage.readWriteTxn([this._storage.storeNames.inboundGroupSessions]); const txn = await this._storage.readWriteTxn([this._storage.storeNames.inboundGroupSessions]);
try { try {
keyIsBestOne = await this._megolmDecryption.writeRoomKey(roomKey, txn); keyIsBestOne = await this._megolmDecryption.writeRoomKey(roomKey, txn);
} catch (err) { } catch (err) {
@ -281,7 +281,7 @@ export class RoomEncryption {
} }
async _shareNewRoomKey(roomKeyMessage, hsApi, log) { async _shareNewRoomKey(roomKeyMessage, hsApi, log) {
let writeOpTxn = this._storage.readWriteTxn([this._storage.storeNames.operations]); let writeOpTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]);
let operation; let operation;
try { try {
operation = this._writeRoomKeyShareOperation(roomKeyMessage, null, writeOpTxn); operation = this._writeRoomKeyShareOperation(roomKeyMessage, null, writeOpTxn);
@ -319,7 +319,7 @@ export class RoomEncryption {
this._isFlushingRoomKeyShares = true; this._isFlushingRoomKeyShares = true;
try { try {
if (!operations) { if (!operations) {
const txn = this._storage.readTxn([this._storage.storeNames.operations]); const txn = await this._storage.readTxn([this._storage.storeNames.operations]);
operations = await txn.operations.getAllByTypeAndScope("share_room_key", this._room.id); operations = await txn.operations.getAllByTypeAndScope("share_room_key", this._room.id);
} }
for (const operation of operations) { for (const operation of operations) {
@ -355,7 +355,7 @@ export class RoomEncryption {
devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log); devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log);
const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set())); const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set()));
operation.userIds = userIds; operation.userIds = userIds;
const userIdsTxn = this._storage.readWriteTxn([this._storage.storeNames.operations]); const userIdsTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]);
try { try {
userIdsTxn.operations.update(operation); userIdsTxn.operations.update(operation);
} catch (err) { } catch (err) {
@ -371,7 +371,7 @@ export class RoomEncryption {
"m.room_key", operation.roomKeyMessage, devices, hsApi, log)); "m.room_key", operation.roomKeyMessage, devices, hsApi, log));
await log.wrap("send", log => this._sendMessagesToDevices(ENCRYPTED_TYPE, messages, hsApi, log)); await log.wrap("send", log => this._sendMessagesToDevices(ENCRYPTED_TYPE, messages, hsApi, log));
const removeTxn = this._storage.readWriteTxn([this._storage.storeNames.operations]); const removeTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]);
try { try {
removeTxn.operations.remove(operation.id); removeTxn.operations.remove(operation.id);
} catch (err) { } catch (err) {

View file

@ -46,7 +46,7 @@ export class Encryption {
async ensureOutboundSession(roomId, encryptionParams) { async ensureOutboundSession(roomId, encryptionParams) {
let session = new this._olm.OutboundGroupSession(); let session = new this._olm.OutboundGroupSession();
try { try {
const txn = this._storage.readWriteTxn([ const txn = await this._storage.readWriteTxn([
this._storage.storeNames.inboundGroupSessions, this._storage.storeNames.inboundGroupSessions,
this._storage.storeNames.outboundGroupSessions, this._storage.storeNames.outboundGroupSessions,
]); ]);
@ -104,7 +104,7 @@ export class Encryption {
async encrypt(roomId, type, content, encryptionParams) { async encrypt(roomId, type, content, encryptionParams) {
let session = new this._olm.OutboundGroupSession(); let session = new this._olm.OutboundGroupSession();
try { try {
const txn = this._storage.readWriteTxn([ const txn = await this._storage.readWriteTxn([
this._storage.storeNames.inboundGroupSessions, this._storage.storeNames.inboundGroupSessions,
this._storage.storeNames.outboundGroupSessions, this._storage.storeNames.outboundGroupSessions,
]); ]);

View file

@ -101,7 +101,7 @@ export class Encryption {
} }
async _findExistingSessions(devices) { async _findExistingSessions(devices) {
const txn = this._storage.readTxn([this._storage.storeNames.olmSessions]); const txn = await this._storage.readTxn([this._storage.storeNames.olmSessions]);
const sessionIdsForDevice = await Promise.all(devices.map(async device => { const sessionIdsForDevice = await Promise.all(devices.map(async device => {
return await txn.olmSessions.getSessionIds(device.curve25519Key); return await txn.olmSessions.getSessionIds(device.curve25519Key);
})); }));
@ -215,7 +215,7 @@ export class Encryption {
} }
async _loadSessions(encryptionTargets) { async _loadSessions(encryptionTargets) {
const txn = this._storage.readTxn([this._storage.storeNames.olmSessions]); const txn = await this._storage.readTxn([this._storage.storeNames.olmSessions]);
// given we run loading in parallel, there might still be some // given we run loading in parallel, there might still be some
// storage requests that will finish later once one has failed. // storage requests that will finish later once one has failed.
// those should not allocate a session anymore. // those should not allocate a session anymore.
@ -241,7 +241,7 @@ export class Encryption {
} }
async _storeSessions(encryptionTargets, timestamp) { async _storeSessions(encryptionTargets, timestamp) {
const txn = this._storage.readWriteTxn([this._storage.storeNames.olmSessions]); const txn = await this._storage.readWriteTxn([this._storage.storeNames.olmSessions]);
try { try {
for (const target of encryptionTargets) { for (const target of encryptionTargets) {
const sessionEntry = createSessionEntry( const sessionEntry = createSessionEntry(

View file

@ -79,7 +79,7 @@ export class Room extends EventEmitter {
if (!this._roomEncryption) { if (!this._roomEncryption) {
return; return;
} }
const txn = this._storage.readTxn([ const txn = await this._storage.readTxn([
this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineEvents,
this._storage.storeNames.inboundGroupSessions, this._storage.storeNames.inboundGroupSessions,
]); ]);
@ -118,7 +118,7 @@ export class Room extends EventEmitter {
_decryptEntries(source, entries, inboundSessionTxn = null) { _decryptEntries(source, entries, inboundSessionTxn = null) {
const request = new DecryptionRequest(async r => { const request = new DecryptionRequest(async r => {
if (!inboundSessionTxn) { if (!inboundSessionTxn) {
inboundSessionTxn = this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]); inboundSessionTxn = await this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]);
} }
if (r.cancelled) return; if (r.cancelled) return;
const events = entries.filter(entry => { const events = entries.filter(entry => {
@ -135,7 +135,7 @@ export class Room extends EventEmitter {
// read to fetch devices if timeline is open // read to fetch devices if timeline is open
stores.push(this._storage.storeNames.deviceIdentities); stores.push(this._storage.storeNames.deviceIdentities);
} }
const writeTxn = this._storage.readWriteTxn(stores); const writeTxn = await this._storage.readWriteTxn(stores);
let decryption; let decryption;
try { try {
decryption = await changes.write(writeTxn); decryption = await changes.write(writeTxn);
@ -472,7 +472,7 @@ export class Room extends EventEmitter {
} }
}, {log}).response(); }, {log}).response();
const txn = this._storage.readWriteTxn([ const txn = await this._storage.readWriteTxn([
this._storage.storeNames.pendingEvents, this._storage.storeNames.pendingEvents,
this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineFragments, this._storage.storeNames.timelineFragments,
@ -584,7 +584,7 @@ export class Room extends EventEmitter {
async _getLastEventId() { async _getLastEventId() {
const lastKey = this._syncWriter.lastMessageKey; const lastKey = this._syncWriter.lastMessageKey;
if (lastKey) { if (lastKey) {
const txn = this._storage.readTxn([ const txn = await this._storage.readTxn([
this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineEvents,
]); ]);
const eventEntry = await txn.timelineEvents.get(this._roomId, lastKey); const eventEntry = await txn.timelineEvents.get(this._roomId, lastKey);
@ -607,7 +607,7 @@ export class Room extends EventEmitter {
if (this.isUnread || this.notificationCount) { if (this.isUnread || this.notificationCount) {
return await this._platform.logger.wrapOrRun(log, "clearUnread", async log => { return await this._platform.logger.wrapOrRun(log, "clearUnread", async log => {
log.set("id", this.id); log.set("id", this.id);
const txn = this._storage.readWriteTxn([ const txn = await this._storage.readWriteTxn([
this._storage.storeNames.roomSummary, this._storage.storeNames.roomSummary,
]); ]);
let data; let data;
@ -706,7 +706,7 @@ export class Room extends EventEmitter {
if (this.isEncrypted) { if (this.isEncrypted) {
stores.push(this._storage.storeNames.inboundGroupSessions); stores.push(this._storage.storeNames.inboundGroupSessions);
} }
const txn = this._storage.readTxn(stores); const txn = await this._storage.readTxn(stores);
const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId); const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId);
if (storageEntry) { if (storageEntry) {
const entry = new EventEntry(storageEntry, this._fragmentIdComparer); const entry = new EventEntry(storageEntry, this._fragmentIdComparer);

View file

@ -251,7 +251,7 @@ export class RoomSummary {
if (data === this._data) { if (data === this._data) {
return false; return false;
} }
const txn = storage.readWriteTxn([ const txn = await storage.readWriteTxn([
storage.storeNames.roomSummary, storage.storeNames.roomSummary,
]); ]);
try { try {

View file

@ -18,7 +18,7 @@ limitations under the License.
import {RoomMember} from "./RoomMember.js"; import {RoomMember} from "./RoomMember.js";
async function loadMembers({roomId, storage}) { async function loadMembers({roomId, storage}) {
const txn = storage.readTxn([ const txn = await storage.readTxn([
storage.storeNames.roomMembers, storage.storeNames.roomMembers,
]); ]);
const memberDatas = await txn.roomMembers.getAll(roomId); const memberDatas = await txn.roomMembers.getAll(roomId);
@ -33,7 +33,7 @@ async function fetchMembers({summary, syncToken, roomId, hsApi, storage, setChan
const memberResponse = await hsApi.members(roomId, {at: syncToken}, {log}).response(); const memberResponse = await hsApi.members(roomId, {at: syncToken}, {log}).response();
const txn = storage.readWriteTxn([ const txn = await storage.readWriteTxn([
storage.storeNames.roomSummary, storage.storeNames.roomSummary,
storage.storeNames.roomMembers, storage.storeNames.roomMembers,
]); ]);

View file

@ -129,7 +129,7 @@ export class SendQueue {
async _removeEvent(pendingEvent) { async _removeEvent(pendingEvent) {
const idx = this._pendingEvents.array.indexOf(pendingEvent); const idx = this._pendingEvents.array.indexOf(pendingEvent);
if (idx !== -1) { if (idx !== -1) {
const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]);
try { try {
txn.pendingEvents.remove(pendingEvent.roomId, pendingEvent.queueIndex); txn.pendingEvents.remove(pendingEvent.roomId, pendingEvent.queueIndex);
} catch (err) { } catch (err) {
@ -185,7 +185,7 @@ export class SendQueue {
} }
async _tryUpdateEvent(pendingEvent) { async _tryUpdateEvent(pendingEvent) {
const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]);
try { try {
// pendingEvent might have been removed already here // pendingEvent might have been removed already here
// by a racing remote echo, so check first so we don't recreate it // by a racing remote echo, so check first so we don't recreate it
@ -200,7 +200,7 @@ export class SendQueue {
} }
async _createAndStoreEvent(eventType, content, attachments) { async _createAndStoreEvent(eventType, content, attachments) {
const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]);
let pendingEvent; let pendingEvent;
try { try {
const pendingEventsStore = txn.pendingEvents; const pendingEventsStore = txn.pendingEvents;

View file

@ -46,7 +46,7 @@ export class Timeline {
/** @package */ /** @package */
async load(user) { async load(user) {
const txn = this._storage.readTxn(this._timelineReader.readTxnStores.concat(this._storage.storeNames.roomMembers)); const txn = await this._storage.readTxn(this._timelineReader.readTxnStores.concat(this._storage.storeNames.roomMembers));
const memberData = await txn.roomMembers.get(this._roomId, user.id); const memberData = await txn.roomMembers.get(this._roomId, user.id);
this._ownMember = new RoomMember(memberData); this._ownMember = new RoomMember(memberData);
// it should be fine to not update the local entries, // it should be fine to not update the local entries,

View file

@ -108,14 +108,14 @@ export class TimelineReader {
readFrom(eventKey, direction, amount) { readFrom(eventKey, direction, amount) {
return new ReaderRequest(async r => { return new ReaderRequest(async r => {
const txn = this._storage.readTxn(this.readTxnStores); const txn = await this._storage.readTxn(this.readTxnStores);
return await this._readFrom(eventKey, direction, amount, r, txn); return await this._readFrom(eventKey, direction, amount, r, txn);
}); });
} }
readFromEnd(amount, existingTxn = null) { readFromEnd(amount, existingTxn = null) {
return new ReaderRequest(async r => { return new ReaderRequest(async r => {
const txn = existingTxn || this._storage.readTxn(this.readTxnStores); const txn = existingTxn || await this._storage.readTxn(this.readTxnStores);
const liveFragment = await txn.timelineFragments.liveFragment(this._roomId); const liveFragment = await txn.timelineFragments.liveFragment(this._roomId);
let entries; let entries;
// room hasn't been synced yet // room hasn't been synced yet

View file

@ -19,7 +19,7 @@ import {keyFromPassphrase} from "./passphrase.js";
import {keyFromRecoveryKey} from "./recoveryKey.js"; import {keyFromRecoveryKey} from "./recoveryKey.js";
async function readDefaultKeyDescription(storage) { async function readDefaultKeyDescription(storage) {
const txn = storage.readTxn([ const txn = await storage.readTxn([
storage.storeNames.accountData storage.storeNames.accountData
]); ]);
const defaultKeyEvent = await txn.accountData.get("m.secret_storage.default_key"); const defaultKeyEvent = await txn.accountData.get("m.secret_storage.default_key");

View file

@ -16,10 +16,14 @@ limitations under the License.
import {Transaction} from "./Transaction.js"; import {Transaction} from "./Transaction.js";
import { STORE_NAMES, StorageError } from "../common.js"; import { STORE_NAMES, StorageError } from "../common.js";
import { reqAsPromise } from "./utils.js";
const WEBKITEARLYCLOSETXNBUG_BOGUS_KEY = "782rh281re38-boguskey";
export class Storage { export class Storage {
constructor(idbDatabase) { constructor(idbDatabase, hasWebkitEarlyCloseTxnBug) {
this._db = idbDatabase; this._db = idbDatabase;
this._hasWebkitEarlyCloseTxnBug = hasWebkitEarlyCloseTxnBug;
const nameMap = STORE_NAMES.reduce((nameMap, name) => { const nameMap = STORE_NAMES.reduce((nameMap, name) => {
nameMap[name] = name; nameMap[name] = name;
return nameMap; return nameMap;
@ -34,20 +38,30 @@ export class Storage {
} }
} }
readTxn(storeNames) { async readTxn(storeNames) {
this._validateStoreNames(storeNames); this._validateStoreNames(storeNames);
try { try {
const txn = this._db.transaction(storeNames, "readonly"); const txn = this._db.transaction(storeNames, "readonly");
// https://bugs.webkit.org/show_bug.cgi?id=222746 workaround,
// await a bogus idb request on the new txn so it doesn't close early if we await a microtask first
if (this._hasWebkitEarlyCloseTxnBug) {
await reqAsPromise(txn.objectStore(storeNames[0]).get(WEBKITEARLYCLOSETXNBUG_BOGUS_KEY));
}
return new Transaction(txn, storeNames); return new Transaction(txn, storeNames);
} catch(err) { } catch(err) {
throw new StorageError("readTxn failed", err); throw new StorageError("readTxn failed", err);
} }
} }
readWriteTxn(storeNames) { async readWriteTxn(storeNames) {
this._validateStoreNames(storeNames); this._validateStoreNames(storeNames);
try { try {
const txn = this._db.transaction(storeNames, "readwrite"); const txn = this._db.transaction(storeNames, "readwrite");
// https://bugs.webkit.org/show_bug.cgi?id=222746 workaround,
// await a bogus idb request on the new txn so it doesn't close early if we await a microtask first
if (this._hasWebkitEarlyCloseTxnBug) {
await reqAsPromise(txn.objectStore(storeNames[0]).get(WEBKITEARLYCLOSETXNBUG_BOGUS_KEY));
}
return new Transaction(txn, storeNames); return new Transaction(txn, storeNames);
} catch(err) { } catch(err) {
throw new StorageError("readWriteTxn failed", err); throw new StorageError("readWriteTxn failed", err);

View file

@ -18,6 +18,7 @@ import {Storage} from "./Storage.js";
import { openDatabase, reqAsPromise } from "./utils.js"; import { openDatabase, reqAsPromise } from "./utils.js";
import { exportSession, importSession } from "./export.js"; import { exportSession, importSession } from "./export.js";
import { schema } from "./schema.js"; import { schema } from "./schema.js";
import { detectWebkitEarlyCloseTxnBug } from "./quirks.js";
const sessionName = sessionId => `hydrogen_session_${sessionId}`; const sessionName = sessionId => `hydrogen_session_${sessionId}`;
const openDatabaseWithSessionId = sessionId => openDatabase(sessionName(sessionId), createStores, schema.length); const openDatabaseWithSessionId = sessionId => openDatabase(sessionName(sessionId), createStores, schema.length);
@ -50,8 +51,10 @@ export class StorageFactory {
console.warn("no persisted storage, database can be evicted by browser"); console.warn("no persisted storage, database can be evicted by browser");
} }
}); });
const hasWebkitEarlyCloseTxnBug = await detectWebkitEarlyCloseTxnBug();
const db = await openDatabaseWithSessionId(sessionId); const db = await openDatabaseWithSessionId(sessionId);
return new Storage(db); return new Storage(db, hasWebkitEarlyCloseTxnBug);
} }
delete(sessionId) { delete(sessionId) {

View file

@ -0,0 +1,41 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {openDatabase, txnAsPromise, reqAsPromise} from "./utils.js";
// filed as https://bugs.webkit.org/show_bug.cgi?id=222746
export async function detectWebkitEarlyCloseTxnBug() {
const dbName = "hydrogen_webkit_test_inactive_txn_bug";
try {
const db = await openDatabase(dbName, db => {
db.createObjectStore("test", {keyPath: "key"});
}, 1);
const readTxn = db.transaction(["test"], "readonly");
await reqAsPromise(readTxn.objectStore("test").get("somekey"));
// schedule a macro task in between the two txns
await new Promise(r => setTimeout(r, 0));
const writeTxn = db.transaction(["test"], "readwrite");
await Promise.resolve();
writeTxn.objectStore("test").add({key: "somekey", value: "foo"});
await txnAsPromise(writeTxn);
} catch (err) {
if (err.name === "TransactionInactiveError") {
return true;
}
}
return false;
}