Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Add mechanism to check only one instance of the app is running #11416

Merged
merged 11 commits into from
Aug 22, 2023
7 changes: 6 additions & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ const config: Config = {
"RecorderWorklet": "<rootDir>/__mocks__/empty.js",
},
transformIgnorePatterns: ["/node_modules/(?!matrix-js-sdk).+$"],
collectCoverageFrom: ["<rootDir>/src/**/*.{js,ts,tsx}"],
collectCoverageFrom: [
"<rootDir>/src/**/*.{js,ts,tsx}",
// getSessionLock is piped into a different JS context via stringification, and the coverage functionality is
// not available in that contest. So, turn off coverage instrumentation for it.
"!<rootDir>/src/utils/SessionLock.ts",
Comment on lines +41 to +43
Copy link
Member Author

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.

],
coverageReporters: ["text-summary", "lcov"],
testResultsProcessor: "@casualbot/jest-sonar-reporter",
};
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
"sanitize-html": "2.11.0",
"tar-js": "^0.3.0",
"ua-parser-js": "^1.0.2",
"uuid": "^9.0.0",
"what-input": "^5.2.10",
"zxcvbn": "^4.4.2"
},
Expand Down
3 changes: 2 additions & 1 deletion sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ sonar.organization=matrix-org

sonar.sources=src,res
sonar.tests=test,cypress
sonar.exclusions=__mocks__,docs
# instrumentation is disabled on SessionLock
sonar.exclusions=__mocks__,docs,src/utils/SessionLock.ts
t3chguy marked this conversation as resolved.
Show resolved Hide resolved

sonar.typescript.tsconfigPath=./tsconfig.json
sonar.javascript.lcov.reportPaths=coverage/lcov.info
Expand Down
221 changes: 221 additions & 0 deletions src/utils/SessionLock.ts
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
Copy link
Member

Choose a reason for hiding this comment

The 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;
}
Loading
Loading