This repository has been archived by the owner on Sep 11, 2024. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 831
Add mechanism to check only one instance of the app is running #11416
Merged
Merged
Changes from 6 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
3fb3504
Add mechanism to check only one instance of the app is running
richvdh 203ddd2
disable instrumentation for SessionLock
richvdh 80c4336
disable coverage reporting
richvdh 81fa0a2
exclude SessionLock in sonar.properties
richvdh 8c5201b
Revert "disable coverage reporting"
richvdh e1008f9
only disable session storage
richvdh 34a6a9e
use pagehide instead of visibilitychange
richvdh 9c05c47
Add `checkSessionLockFree`
richvdh 2879269
Give up waiting for a lock immediately when someone else claims
richvdh 44aa589
Merge remote-tracking branch 'origin/develop' into rav/element-r/tab_…
richvdh 7d03d09
Update src/utils/SessionLock.ts
richvdh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,221 @@ | ||
/* | ||
Copyright 2023 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 { logger } from "matrix-js-sdk/src/logger"; | ||
import { v4 as uuidv4 } from "uuid"; | ||
|
||
/** | ||
* Ensure that only one instance of the application is running at once. | ||
* | ||
* If there are any other running instances, tells them to stop, and waits for them to do so. | ||
* | ||
* Once we are the sole instance, sets a background job going to service a lock. Then, if another instance starts up, | ||
* `onNewInstance` is called: it should shut the app down to make sure we aren't doing any more work. | ||
* | ||
* @param onNewInstance - callback to handle another instance starting up. NOTE: this may be called before | ||
* `getSessionLock` returns if the lock is stolen before we get a chance to start. | ||
* | ||
* @returns true if we successfully claimed the lock; false if another instance stole it from under our nose | ||
* (in which `onNewInstance` will have been called) | ||
*/ | ||
export async function getSessionLock(onNewInstance: () => Promise<void>): Promise<boolean> { | ||
/* | ||
* The algorithm here is twofold. | ||
* | ||
* First, we "claim" a lock by periodically writing to `STORAGE_ITEM_PING`. On shutdown, we clear that item. So, | ||
* a new instance starting up can check if the lock is free by inspecting `STORAGE_ITEM_PING`. If it is unset, | ||
* or is stale, the new instance can assume the lock is free and claim it for itself. Otherwise, the new instance | ||
* has to wait for the ping to be stale, or the item to be cleared. | ||
* | ||
* Secondly, we need a mechanism for proactively telling existing instances to shut down. We do this by writing a | ||
* unique value to `STORAGE_ITEM_CLAIMANT`. Other instances of the app are supposed to monitor for writes to | ||
* `STORAGE_ITEM_CLAIMANT` and initiate shutdown when it happens. | ||
* | ||
* There is slight complexity in `STORAGE_ITEM_CLAIMANT` in that we need to watch out for yet another instance | ||
* starting up and staking a claim before we even get a chance to take the lock. When that happens we just bail out | ||
* and let the newer instance get the lock. | ||
* | ||
* `STORAGE_ITEM_OWNER` has no functional role in the lock mechanism; it exists solely as a diagnostic indicator | ||
* of which instance is writing to `STORAGE_ITEM_PING`. | ||
*/ | ||
|
||
/** | ||
* LocalStorage key for an item which indicates we have the lock. | ||
* | ||
* The instance which holds the lock writes the current time to this key every few seconds, to indicate it is still | ||
* alive and holds the lock. | ||
*/ | ||
const STORAGE_ITEM_PING = "react_sdk_session_lock_ping"; | ||
|
||
/** | ||
* LocalStorage key for an item which holds the unique "session ID" of the instance which currently holds the lock. | ||
* | ||
* This property doesn't actually form a functional part of the locking algorithm; it is purely diagnostic. | ||
*/ | ||
const STORAGE_ITEM_OWNER = "react_sdk_session_lock_owner"; | ||
|
||
/** | ||
* LocalStorage key for the session ID of the most recent claimant to the lock. | ||
* | ||
* Each instance writes to this key on startup, so existing instances can detect new ones starting up. | ||
*/ | ||
const STORAGE_ITEM_CLAIMANT = "react_sdk_session_lock_claimant"; | ||
|
||
/** unique ID for this session */ | ||
const sessionIdentifier = uuidv4(); | ||
|
||
const prefixedLogger = logger.withPrefix(`getSessionLock[${sessionIdentifier}]`); | ||
|
||
/** The ID of our regular task to service the lock. | ||
* | ||
* Non-null while we hold the lock; null if we have not yet claimed it, or have released it. */ | ||
let lockServicer: number | null = null; | ||
|
||
/** | ||
* See if the lock is free. | ||
* | ||
* @returns | ||
* - `>0`: the number of milliseconds before the current claim on the lock can be considered stale. | ||
* - `0`: the lock is free for the taking | ||
* - `<0`: someone else has staked a claim for the lock, so we are no longer in line for it. | ||
*/ | ||
function checkLock(): number { | ||
// first of all, check that we are still the active claimant (ie, another instance hasn't come along while we were waiting. | ||
const claimant = window.localStorage.getItem(STORAGE_ITEM_CLAIMANT); | ||
if (claimant !== sessionIdentifier) { | ||
prefixedLogger.warn(`Lock was claimed by ${claimant} while we were waiting for it: aborting startup`); | ||
return -1; | ||
} | ||
|
||
const lastPingTime = window.localStorage.getItem(STORAGE_ITEM_PING); | ||
const lockHolder = window.localStorage.getItem(STORAGE_ITEM_OWNER); | ||
if (lastPingTime === null) { | ||
prefixedLogger.info("No other session has the lock: proceeding with startup"); | ||
return 0; | ||
} | ||
|
||
const timeAgo = Date.now() - parseInt(lastPingTime); | ||
const remaining = 30000 - timeAgo; | ||
if (remaining <= 0) { | ||
// another session claimed the lock, but it is stale. | ||
prefixedLogger.info(`Last ping (from ${lockHolder}) was ${timeAgo}ms ago: proceeding with startup`); | ||
return 0; | ||
} | ||
|
||
prefixedLogger.info(`Last ping (from ${lockHolder}) was ${timeAgo}ms ago, waiting`); | ||
return remaining; | ||
} | ||
|
||
function serviceLock(): void { | ||
window.localStorage.setItem(STORAGE_ITEM_OWNER, sessionIdentifier); | ||
window.localStorage.setItem(STORAGE_ITEM_PING, Date.now().toString()); | ||
} | ||
|
||
// handler for storage events, used later | ||
function onStorageEvent(event: StorageEvent): void { | ||
if (event.key === STORAGE_ITEM_CLAIMANT) { | ||
// It's possible that the event was delayed, and this update actually predates our claim on the lock. | ||
// (In particular: suppose tab A and tab B start concurrently and both attempt to set STORAGE_ITEM_CLAIMANT. | ||
// Each write queues up a `storage` event for all other tabs. So both tabs see the `storage` event from the | ||
// other, even though by the time it arrives we may have overwritten it.) | ||
// | ||
// To resolve any doubt, we check the *actual* state of the storage. | ||
const claimingSession = window.localStorage.getItem(STORAGE_ITEM_CLAIMANT); | ||
if (claimingSession === sessionIdentifier) { | ||
return; | ||
} | ||
prefixedLogger.info(`Session ${claimingSession} is waiting for the lock`); | ||
window.removeEventListener("storage", onStorageEvent); | ||
releaseLock().catch((err) => { | ||
prefixedLogger.error("Error releasing session lock", err); | ||
}); | ||
} | ||
} | ||
|
||
async function releaseLock(): Promise<void> { | ||
// tell the app to shut down | ||
await onNewInstance(); | ||
|
||
// and, once it has done so, stop pinging the lock. | ||
if (lockServicer !== null) { | ||
clearInterval(lockServicer); | ||
} | ||
window.localStorage.removeItem(STORAGE_ITEM_PING); | ||
window.localStorage.removeItem(STORAGE_ITEM_OWNER); | ||
lockServicer = null; | ||
} | ||
|
||
// first of all, stake a claim for the lock. This tells anyone else holding the lock that we want it. | ||
window.localStorage.setItem(STORAGE_ITEM_CLAIMANT, sessionIdentifier); | ||
|
||
// now, wait for the lock to be free. | ||
// eslint-disable-next-line no-constant-condition | ||
while (true) { | ||
const remaining = checkLock(); | ||
|
||
if (remaining == 0) { | ||
// ok, the lock is free, and nobody else has staked a more recent claim. | ||
break; | ||
} else if (remaining < 0) { | ||
// someone else staked a claim for the lock; we bail out. | ||
await onNewInstance(); | ||
return false; | ||
} | ||
|
||
// someone else has the lock. | ||
// wait for either the ping to expire, or a storage event. | ||
let onStorageUpdate: (event: StorageEvent) => void; | ||
|
||
const storageUpdatePromise = new Promise((resolve) => { | ||
onStorageUpdate = (event: StorageEvent) => { | ||
if (event.key === STORAGE_ITEM_PING) resolve(event); | ||
}; | ||
}); | ||
|
||
const sleepPromise = new Promise((resolve) => { | ||
setTimeout(resolve, remaining, undefined); | ||
}); | ||
Comment on lines
+218
to
+220
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. js-sdk utils has a sleep function which does just this |
||
|
||
window.addEventListener("storage", onStorageUpdate!); | ||
await Promise.race([sleepPromise, storageUpdatePromise]); | ||
window.removeEventListener("storage", onStorageUpdate!); | ||
} | ||
|
||
// If we get here, we know the lock is ours for the taking. | ||
|
||
// CRITICAL SECTION | ||
// | ||
// The following code, up to the end of the function, must all be synchronous (ie, no `await` calls), to ensure that | ||
// we get our listeners in place and all the writes to localStorage done before other tabs run again. | ||
|
||
// claim the lock, and kick off a background process to service it every 5 seconds | ||
serviceLock(); | ||
lockServicer = setInterval(serviceLock, 5000); | ||
|
||
// Now add a listener for other claimants to the lock. | ||
window.addEventListener("storage", onStorageEvent); | ||
|
||
// also add a listener to clear our claims when our tab closes (provided we haven't released it already) | ||
window.document.addEventListener("visibilitychange", (event) => { | ||
richvdh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (window.document.visibilityState === "hidden" && lockServicer !== null) { | ||
prefixedLogger.info("Unloading: clearing our claims"); | ||
window.localStorage.removeItem(STORAGE_ITEM_PING); | ||
window.localStorage.removeItem(STORAGE_ITEM_OWNER); | ||
} | ||
}); | ||
|
||
return true; | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As I wrote in
#element-dev
, I've yet to think of a better solution to this.