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

Commit 0ba71b0

Browse files
committed
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.
1 parent 7a6d81c commit 0ba71b0

File tree

4 files changed

+459
-1
lines changed

4 files changed

+459
-1
lines changed

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
},

src/utils/SessionLock.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
* Ensure that only one instance of the application is running at once.
22+
*
23+
* If there are any other running instances, tells them to stop, and waits for them to do so.
24+
*
25+
* Once we are the sole instance, sets a background job going to service a lock. Then, if another instance starts up,
26+
* `onNewInstance` is called: it should shut the app down to make sure we aren't doing any more work.
27+
*
28+
* @param onNewInstance - callback to handle another instance starting up. NOTE: this may be called before
29+
* `getSessionLock` returns if the lock is stolen before we get a chance to start.
30+
*
31+
* @returns true if we successfully claimed the lock; false if another instance stole it from under our nose
32+
* (in which `onNewInstance` will have been called)
33+
*/
34+
export async function getSessionLock(onNewInstance: () => Promise<void>): Promise<boolean> {
35+
/*
36+
* The algorithm here is twofold.
37+
*
38+
* First, we "claim" a lock by periodically writing to `STORAGE_ITEM_PING`. On shutdown, we clear that item. So,
39+
* a new instance starting up can check if the lock is free by inspecting `STORAGE_ITEM_PING`. If it is unset,
40+
* or is stale, the new instance can assume the lock is free and claim it for itself. Otherwise, the new instance
41+
* has to wait for the ping to be stale, or the item to be cleared.
42+
*
43+
* Secondly, we need a mechanism for proactively telling existing instances to shut down. We do this by writing a
44+
* unique value to `STORAGE_ITEM_CLAIMANT`. Other instances of the app are supposed to monitor for writes to
45+
* `STORAGE_ITEM_CLAIMANT` and initiate shutdown when it happens.
46+
*
47+
* There is slight complexity in `STORAGE_ITEM_CLAIMANT` in that we need to watch out for yet another instance
48+
* starting up and staking a claim before we even get a chance to take the lock. When that happens we just bail out
49+
* and let the newer instance get the lock.
50+
*
51+
* `STORAGE_ITEM_OWNER` has no functional role in the lock mechanism; it exists solely as a diagnostic indicator
52+
* of which instance is writing to `STORAGE_ITEM_PING`.
53+
*/
54+
55+
/**
56+
* LocalStorage key for an item which indicates we have the lock.
57+
*
58+
* The instance which holds the lock writes the current time to this key every few seconds, to indicate it is still
59+
* alive and holds the lock.
60+
*/
61+
const STORAGE_ITEM_PING = "react_sdk_session_lock_ping";
62+
63+
/**
64+
* LocalStorage key for an item which holds the unique "session ID" of the instance which currently holds the lock.
65+
*
66+
* This property doesn't actually form a functional part of the locking algorithm; it is purely diagnostic.
67+
*/
68+
const STORAGE_ITEM_OWNER = "react_sdk_session_lock_owner";
69+
70+
/**
71+
* LocalStorage key for the session ID of the most recent claimant to the lock.
72+
*
73+
* Each instance writes to this key on startup, so existing instances can detect new ones starting up.
74+
*/
75+
const STORAGE_ITEM_CLAIMANT = "react_sdk_session_lock_claimant";
76+
77+
const sessionIdentifier = uuidv4();
78+
const prefixedLogger = logger.withPrefix(`getSessionLock[${sessionIdentifier}]`);
79+
80+
/** The ID of our regular task to service the lock.
81+
*
82+
* Non-null while we hold the lock; null if we have not yet claimed it, or have released it. */
83+
let lockServicer: number | null = null;
84+
85+
// See if the lock is free.
86+
//
87+
// Returns:
88+
// >0: the number of milliseconds before the current claim on the lock can be considered stale.
89+
// 0: the lock is free for the taking
90+
// <0: someone else has staked a claim for the lock, so we are no longer in line for it.
91+
function checkLock(): number {
92+
// first of all, check that we are still the active claimant (ie, another instance hasn't come along while we were waiting.
93+
const claimant = window.localStorage.getItem(STORAGE_ITEM_CLAIMANT);
94+
if (claimant !== sessionIdentifier) {
95+
prefixedLogger.warn(`Lock was claimed by ${claimant} while we were waiting for it: aborting startup`);
96+
return -1;
97+
}
98+
99+
const lastPingTime = window.localStorage.getItem(STORAGE_ITEM_PING);
100+
const lockHolder = window.localStorage.getItem(STORAGE_ITEM_OWNER);
101+
if (lastPingTime === null) {
102+
prefixedLogger.info("No other session has the lock: proceeding with startup");
103+
return 0;
104+
}
105+
106+
const timeAgo = Date.now() - parseInt(lastPingTime);
107+
const remaining = 30000 - timeAgo;
108+
if (remaining <= 0) {
109+
// another session claimed the lock, but it is stale.
110+
prefixedLogger.info(`Last ping (from ${lockHolder}) was ${timeAgo}ms ago: proceeding with startup`);
111+
return 0;
112+
}
113+
114+
prefixedLogger.info(`Last ping (from ${lockHolder}) was ${timeAgo}ms ago, waiting`);
115+
return remaining;
116+
}
117+
118+
function serviceLock(): void {
119+
window.localStorage.setItem(STORAGE_ITEM_OWNER, sessionIdentifier);
120+
window.localStorage.setItem(STORAGE_ITEM_PING, Date.now().toString());
121+
}
122+
123+
// handler for storage events, used later
124+
function onStorageEvent(event: StorageEvent): void {
125+
if (event.key === STORAGE_ITEM_CLAIMANT) {
126+
// It's possible that the event was delayed, and this update actually predates our claim on the lock.
127+
// (In particular: suppose tab A and tab B start concurrently and both attempt to set STORAGE_ITEM_CLAIMANT.
128+
// Each write queues up a `storage` event for all other tabs. So both tabs see the `storage` event from the
129+
// other, even though by the time it arrives we may have overwritten it.)
130+
//
131+
// To resolve any doubt, we check the *actual* state of the storage.
132+
const claimingSession = window.localStorage.getItem(STORAGE_ITEM_CLAIMANT);
133+
if (claimingSession === sessionIdentifier) {
134+
return;
135+
}
136+
prefixedLogger.info(`Session ${claimingSession} is waiting for the lock`);
137+
window.removeEventListener("storage", onStorageEvent);
138+
releaseLock().catch((err) => {
139+
prefixedLogger.error("Error releasing session lock", err);
140+
});
141+
}
142+
}
143+
onStorageEvent.sid = sessionIdentifier;
144+
145+
async function releaseLock(): Promise<void> {
146+
// tell the app to shut down
147+
await onNewInstance();
148+
149+
// and, once it has done so, stop pinging the lock.
150+
if (lockServicer !== null) {
151+
clearInterval(lockServicer);
152+
}
153+
window.localStorage.removeItem(STORAGE_ITEM_PING);
154+
window.localStorage.removeItem(STORAGE_ITEM_OWNER);
155+
lockServicer = null;
156+
}
157+
158+
// first of all, stake a claim for the lock. This tells anyone else holding the lock that we want it.
159+
window.localStorage.setItem(STORAGE_ITEM_CLAIMANT, sessionIdentifier);
160+
161+
// now, wait for the lock to be free.
162+
// eslint-disable-next-line no-constant-condition
163+
while (true) {
164+
const remaining = checkLock();
165+
166+
if (remaining == 0) {
167+
// ok, the lock is free, and nobody else has staked a more recent claim.
168+
break;
169+
} else if (remaining < 0) {
170+
// someone else staked a claim for the lock; we bail out.
171+
await onNewInstance();
172+
return false;
173+
}
174+
175+
// someone else has the lock.
176+
// wait for either the ping to expire, or a storage event.
177+
let onStorageUpdate: (event: StorageEvent) => void;
178+
179+
const storageUpdatePromise = new Promise((resolve) => {
180+
onStorageUpdate = (event: StorageEvent) => {
181+
if (event.key === STORAGE_ITEM_PING) resolve(event);
182+
};
183+
});
184+
185+
const sleepPromise = new Promise((resolve) => {
186+
setTimeout(resolve, remaining, undefined);
187+
});
188+
189+
window.addEventListener("storage", onStorageUpdate!);
190+
await Promise.race([sleepPromise, storageUpdatePromise]);
191+
window.removeEventListener("storage", onStorageUpdate!);
192+
}
193+
194+
// If we get here, we know the lock is ours for the taking.
195+
196+
// CRITICAL SECTION
197+
//
198+
// The following code, up to the end of the function, must all be synchronous (ie, no `await` calls), to ensure that
199+
// we get our listeners in place and all the writes to localStorage done before other tabs run again.
200+
201+
// claim the lock, and kick off a background process to service it every 5 seconds
202+
serviceLock();
203+
lockServicer = setInterval(serviceLock, 5000);
204+
205+
// Now add a listener for other claimants to the lock.
206+
window.addEventListener("storage", onStorageEvent);
207+
208+
// also add a listener to clear our claims when our tab closes (provided we haven't released it already)
209+
window.document.addEventListener("visibilitychange", (event) => {
210+
if (window.document.visibilityState === "hidden" && lockServicer !== null) {
211+
prefixedLogger.info("Unloading: clearing our claims");
212+
window.localStorage.removeItem(STORAGE_ITEM_PING);
213+
window.localStorage.removeItem(STORAGE_ITEM_OWNER);
214+
}
215+
});
216+
217+
return true;
218+
}

0 commit comments

Comments
 (0)