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

Commit d13b6e1

Browse files
authored
Add mechanism to check only one instance of the app is running (#11416)
* Add mechanism to check only one instance of the app is running This isn't used yet, but will form part of the solution to element-hq/element-web#25157. * disable instrumentation for SessionLock * disable coverage reporting * exclude SessionLock in sonar.properties * Revert "disable coverage reporting" This reverts commit 80c4336. * only disable session storage * use pagehide instead of visibilitychange * Add `checkSessionLockFree` * Give up waiting for a lock immediately when someone else claims * Update src/utils/SessionLock.ts
1 parent 4de315f commit d13b6e1

File tree

6 files changed

+546
-3
lines changed

6 files changed

+546
-3
lines changed

jest.config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ const config: Config = {
3636
"RecorderWorklet": "<rootDir>/__mocks__/empty.js",
3737
},
3838
transformIgnorePatterns: ["/node_modules/(?!matrix-js-sdk).+$"],
39-
collectCoverageFrom: ["<rootDir>/src/**/*.{js,ts,tsx}"],
39+
collectCoverageFrom: [
40+
"<rootDir>/src/**/*.{js,ts,tsx}",
41+
// getSessionLock is piped into a different JS context via stringification, and the coverage functionality is
42+
// not available in that contest. So, turn off coverage instrumentation for it.
43+
"!<rootDir>/src/utils/SessionLock.ts",
44+
],
4045
coverageReporters: ["text-summary", "lcov"],
4146
testResultsProcessor: "@casualbot/jest-sonar-reporter",
4247
};

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
"sanitize-html": "2.11.0",
122122
"tar-js": "^0.3.0",
123123
"ua-parser-js": "^1.0.2",
124+
"uuid": "^9.0.0",
124125
"what-input": "^5.2.10",
125126
"zxcvbn": "^4.4.2"
126127
},

sonar-project.properties

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ sonar.exclusions=__mocks__,docs
1111
sonar.cpd.exclusions=src/i18n/strings/*.json
1212
sonar.typescript.tsconfigPath=./tsconfig.json
1313
sonar.javascript.lcov.reportPaths=coverage/lcov.info
14-
sonar.coverage.exclusions=test/**/*,cypress/**/*,src/components/views/dialogs/devtools/**/*
14+
# instrumentation is disabled on SessionLock
15+
sonar.coverage.exclusions=test/**/*,cypress/**/*,src/components/views/dialogs/devtools/**/*,src/utils/SessionLock.ts
1516
sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml

src/utils/SessionLock.ts

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { logger } from "matrix-js-sdk/src/logger";
18+
import { v4 as uuidv4 } from "uuid";
19+
20+
/*
21+
* Functionality for checking that only one instance is running at once
22+
*
23+
* The algorithm here is twofold.
24+
*
25+
* First, we "claim" a lock by periodically writing to `STORAGE_ITEM_PING`. On shutdown, we clear that item. So,
26+
* a new instance starting up can check if the lock is free by inspecting `STORAGE_ITEM_PING`. If it is unset,
27+
* or is stale, the new instance can assume the lock is free and claim it for itself. Otherwise, the new instance
28+
* has to wait for the ping to be stale, or the item to be cleared.
29+
*
30+
* Secondly, we need a mechanism for proactively telling existing instances to shut down. We do this by writing a
31+
* unique value to `STORAGE_ITEM_CLAIMANT`. Other instances of the app are supposed to monitor for writes to
32+
* `STORAGE_ITEM_CLAIMANT` and initiate shutdown when it happens.
33+
*
34+
* There is slight complexity in `STORAGE_ITEM_CLAIMANT` in that we need to watch out for yet another instance
35+
* starting up and staking a claim before we even get a chance to take the lock. When that happens we just bail out
36+
* and let the newer instance get the lock.
37+
*
38+
* `STORAGE_ITEM_OWNER` has no functional role in the lock mechanism; it exists solely as a diagnostic indicator
39+
* of which instance is writing to `STORAGE_ITEM_PING`.
40+
*/
41+
42+
export const SESSION_LOCK_CONSTANTS = {
43+
/**
44+
* LocalStorage key for an item which indicates we have the lock.
45+
*
46+
* The instance which holds the lock writes the current time to this key every few seconds, to indicate it is still
47+
* alive and holds the lock.
48+
*/
49+
STORAGE_ITEM_PING: "react_sdk_session_lock_ping",
50+
51+
/**
52+
* LocalStorage key for an item which holds the unique "session ID" of the instance which currently holds the lock.
53+
*
54+
* This property doesn't actually form a functional part of the locking algorithm; it is purely diagnostic.
55+
*/
56+
STORAGE_ITEM_OWNER: "react_sdk_session_lock_owner",
57+
58+
/**
59+
* LocalStorage key for the session ID of the most recent claimant to the lock.
60+
*
61+
* Each instance writes to this key on startup, so existing instances can detect new ones starting up.
62+
*/
63+
STORAGE_ITEM_CLAIMANT: "react_sdk_session_lock_claimant",
64+
65+
/**
66+
* The number of milliseconds after which we consider a lock claim stale
67+
*/
68+
LOCK_EXPIRY_TIME_MS: 30000,
69+
};
70+
71+
/**
72+
* See if any instances are currently running
73+
*
74+
* @returns true if any instance is currently active
75+
*/
76+
export function checkSessionLockFree(): boolean {
77+
const lastPingTime = window.localStorage.getItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_PING);
78+
if (lastPingTime === null) {
79+
// no other holder
80+
return true;
81+
}
82+
83+
// see if it has expired
84+
const timeAgo = Date.now() - parseInt(lastPingTime);
85+
return timeAgo > SESSION_LOCK_CONSTANTS.LOCK_EXPIRY_TIME_MS;
86+
}
87+
88+
/**
89+
* Ensure that only one instance of the application is running at once.
90+
*
91+
* If there are any other running instances, tells them to stop, and waits for them to do so.
92+
*
93+
* Once we are the sole instance, sets a background job going to service a lock. Then, if another instance starts up,
94+
* `onNewInstance` is called: it should shut the app down to make sure we aren't doing any more work.
95+
*
96+
* @param onNewInstance - callback to handle another instance starting up. NOTE: this may be called before
97+
* `getSessionLock` returns if the lock is stolen before we get a chance to start.
98+
*
99+
* @returns true if we successfully claimed the lock; false if another instance stole it from under our nose
100+
* (in which `onNewInstance` will have been called)
101+
*/
102+
export async function getSessionLock(onNewInstance: () => Promise<void>): Promise<boolean> {
103+
/** unique ID for this session */
104+
const sessionIdentifier = uuidv4();
105+
106+
const prefixedLogger = logger.withPrefix(`getSessionLock[${sessionIdentifier}]`);
107+
108+
/** The ID of our regular task to service the lock.
109+
*
110+
* Non-null while we hold the lock; null if we have not yet claimed it, or have released it. */
111+
let lockServicer: number | null = null;
112+
113+
/**
114+
* See if the lock is free.
115+
*
116+
* @returns
117+
* - `>0`: the number of milliseconds before the current claim on the lock can be considered stale.
118+
* - `0`: the lock is free for the taking
119+
* - `<0`: someone else has staked a claim for the lock, so we are no longer in line for it.
120+
*/
121+
function checkLock(): number {
122+
// first of all, check that we are still the active claimant (ie, another instance hasn't come along while we were waiting.
123+
const claimant = window.localStorage.getItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_CLAIMANT);
124+
if (claimant !== sessionIdentifier) {
125+
prefixedLogger.warn(`Lock was claimed by ${claimant} while we were waiting for it: aborting startup`);
126+
return -1;
127+
}
128+
129+
const lastPingTime = window.localStorage.getItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_PING);
130+
const lockHolder = window.localStorage.getItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_OWNER);
131+
if (lastPingTime === null) {
132+
prefixedLogger.info("No other session has the lock: proceeding with startup");
133+
return 0;
134+
}
135+
136+
const timeAgo = Date.now() - parseInt(lastPingTime);
137+
const remaining = SESSION_LOCK_CONSTANTS.LOCK_EXPIRY_TIME_MS - timeAgo;
138+
if (remaining <= 0) {
139+
// another session claimed the lock, but it is stale.
140+
prefixedLogger.info(`Last ping (from ${lockHolder}) was ${timeAgo}ms ago: proceeding with startup`);
141+
return 0;
142+
}
143+
144+
prefixedLogger.info(`Last ping (from ${lockHolder}) was ${timeAgo}ms ago, waiting`);
145+
return remaining;
146+
}
147+
148+
function serviceLock(): void {
149+
window.localStorage.setItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_OWNER, sessionIdentifier);
150+
window.localStorage.setItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_PING, Date.now().toString());
151+
}
152+
153+
// handler for storage events, used later
154+
function onStorageEvent(event: StorageEvent): void {
155+
if (event.key === SESSION_LOCK_CONSTANTS.STORAGE_ITEM_CLAIMANT) {
156+
// It's possible that the event was delayed, and this update actually predates our claim on the lock.
157+
// (In particular: suppose tab A and tab B start concurrently and both attempt to set STORAGE_ITEM_CLAIMANT.
158+
// Each write queues up a `storage` event for all other tabs. So both tabs see the `storage` event from the
159+
// other, even though by the time it arrives we may have overwritten it.)
160+
//
161+
// To resolve any doubt, we check the *actual* state of the storage.
162+
const claimingSession = window.localStorage.getItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_CLAIMANT);
163+
if (claimingSession === sessionIdentifier) {
164+
return;
165+
}
166+
prefixedLogger.info(`Session ${claimingSession} is waiting for the lock`);
167+
window.removeEventListener("storage", onStorageEvent);
168+
releaseLock().catch((err) => {
169+
prefixedLogger.error("Error releasing session lock", err);
170+
});
171+
}
172+
}
173+
174+
async function releaseLock(): Promise<void> {
175+
// tell the app to shut down
176+
await onNewInstance();
177+
178+
// and, once it has done so, stop pinging the lock.
179+
if (lockServicer !== null) {
180+
clearInterval(lockServicer);
181+
}
182+
window.localStorage.removeItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_PING);
183+
window.localStorage.removeItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_OWNER);
184+
lockServicer = null;
185+
}
186+
187+
// first of all, stake a claim for the lock. This tells anyone else holding the lock that we want it.
188+
window.localStorage.setItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_CLAIMANT, sessionIdentifier);
189+
190+
// now, wait for the lock to be free.
191+
// eslint-disable-next-line no-constant-condition
192+
while (true) {
193+
const remaining = checkLock();
194+
195+
if (remaining == 0) {
196+
// ok, the lock is free, and nobody else has staked a more recent claim.
197+
break;
198+
} else if (remaining < 0) {
199+
// someone else staked a claim for the lock; we bail out.
200+
await onNewInstance();
201+
return false;
202+
}
203+
204+
// someone else has the lock.
205+
// wait for either the ping to expire, or a storage event.
206+
let onStorageUpdate: (event: StorageEvent) => void;
207+
208+
const storageUpdatePromise = new Promise((resolve) => {
209+
onStorageUpdate = (event: StorageEvent) => {
210+
if (
211+
event.key === SESSION_LOCK_CONSTANTS.STORAGE_ITEM_PING ||
212+
event.key === SESSION_LOCK_CONSTANTS.STORAGE_ITEM_CLAIMANT
213+
)
214+
resolve(event);
215+
};
216+
});
217+
218+
const sleepPromise = new Promise((resolve) => {
219+
setTimeout(resolve, remaining, undefined);
220+
});
221+
222+
window.addEventListener("storage", onStorageUpdate!);
223+
await Promise.race([sleepPromise, storageUpdatePromise]);
224+
window.removeEventListener("storage", onStorageUpdate!);
225+
}
226+
227+
// If we get here, we know the lock is ours for the taking.
228+
229+
// CRITICAL SECTION
230+
//
231+
// The following code, up to the end of the function, must all be synchronous (ie, no `await` calls), to ensure that
232+
// we get our listeners in place and all the writes to localStorage done before other tabs run again.
233+
234+
// claim the lock, and kick off a background process to service it every 5 seconds
235+
serviceLock();
236+
lockServicer = setInterval(serviceLock, 5000);
237+
238+
// Now add a listener for other claimants to the lock.
239+
window.addEventListener("storage", onStorageEvent);
240+
241+
// also add a listener to clear our claims when our tab closes or navigates away
242+
window.addEventListener("pagehide", (event) => {
243+
// only remove the ping if we still think we're the owner. Otherwise we could be removing someone else's claim!
244+
if (lockServicer !== null) {
245+
prefixedLogger.debug("page hide: clearing our claim");
246+
window.localStorage.removeItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_PING);
247+
window.localStorage.removeItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_OWNER);
248+
}
249+
250+
// It's worth noting that, according to the spec, the page might come back to life again after a pagehide.
251+
//
252+
// In practice that's unlikely because Element is unlikely to qualify for the bfcache, but if it does,
253+
// this is probably the best we can do: we certainly don't want to stop the user loading any new tabs because
254+
// Element happens to be in a bfcache somewhere.
255+
//
256+
// So, we just hope that we aren't in the middle of any crypto operations, and rely on `onStorageEvent` kicking
257+
// in soon enough after we resume to tell us if another tab woke up while we were asleep.
258+
});
259+
260+
return true;
261+
}

0 commit comments

Comments
 (0)