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 https://github.com/vector-im/element-web/issues/25157. * disable instrumentation for SessionLock * disable coverage reporting * exclude SessionLock in sonar.properties * Revert "disable coverage reporting" This reverts commit 80c4336f76ec8e47e376b6744ef612a73299d14f. * 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
This commit is contained in:
parent
4de315fb6c
commit
d13b6e1b41
6 changed files with 546 additions and 3 deletions
275
test/utils/SessionLock-test.ts
Normal file
275
test/utils/SessionLock-test.ts
Normal file
|
@ -0,0 +1,275 @@
|
|||
/*
|
||||
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 { checkSessionLockFree, getSessionLock, SESSION_LOCK_CONSTANTS } from "../../src/utils/SessionLock";
|
||||
|
||||
describe("SessionLock", () => {
|
||||
const otherWindows: Array<Window> = [];
|
||||
let windowEventListeners: Array<[string, any]>;
|
||||
let documentEventListeners: Array<[string, any]>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ now: 1000 });
|
||||
|
||||
// keep track of the registered event listeners, so that we can unregister them in `afterEach`
|
||||
windowEventListeners = [];
|
||||
const realWindowAddEventListener = window.addEventListener.bind(window);
|
||||
jest.spyOn(window, "addEventListener").mockImplementation((type, listener, options) => {
|
||||
const res = realWindowAddEventListener(type, listener, options);
|
||||
windowEventListeners.push([type, listener]);
|
||||
return res;
|
||||
});
|
||||
|
||||
documentEventListeners = [];
|
||||
const realDocumentAddEventListener = document.addEventListener.bind(document);
|
||||
jest.spyOn(document, "addEventListener").mockImplementation((type, listener, options) => {
|
||||
const res = realDocumentAddEventListener(type, listener, options);
|
||||
documentEventListeners.push([type, listener]);
|
||||
return res;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// shut down other windows created by `createWindow`
|
||||
otherWindows.forEach((window) => window.close());
|
||||
otherWindows.splice(0);
|
||||
|
||||
// remove listeners on our own window
|
||||
windowEventListeners.forEach(([type, listener]) => window.removeEventListener(type, listener));
|
||||
documentEventListeners.forEach(([type, listener]) => document.removeEventListener(type, listener));
|
||||
|
||||
localStorage.clear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("A single instance starts up normally", async () => {
|
||||
const onNewInstance = jest.fn();
|
||||
const result = await getSessionLock(onNewInstance);
|
||||
expect(result).toBe(true);
|
||||
expect(onNewInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("A second instance starts up normally when the first shut down cleanly", async () => {
|
||||
// first instance starts...
|
||||
const onNewInstance1 = jest.fn();
|
||||
expect(await getSessionLock(onNewInstance1)).toBe(true);
|
||||
expect(onNewInstance1).not.toHaveBeenCalled();
|
||||
|
||||
// ... and navigates away
|
||||
window.dispatchEvent(new Event("pagehide", {}));
|
||||
|
||||
// second instance starts as normal
|
||||
expect(checkSessionLockFree()).toBe(true);
|
||||
const onNewInstance2 = jest.fn();
|
||||
expect(await getSessionLock(onNewInstance2)).toBe(true);
|
||||
|
||||
expect(onNewInstance1).not.toHaveBeenCalled();
|
||||
expect(onNewInstance2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("A second instance starts up *eventually* when the first terminated uncleanly", async () => {
|
||||
// first instance starts...
|
||||
const onNewInstance1 = jest.fn();
|
||||
expect(await getSessionLock(onNewInstance1)).toBe(true);
|
||||
expect(onNewInstance1).not.toHaveBeenCalled();
|
||||
expect(checkSessionLockFree()).toBe(false);
|
||||
|
||||
// and pings the timer after 5 seconds
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(checkSessionLockFree()).toBe(false);
|
||||
|
||||
// oops, now it dies. We simulate this by forcibly clearing the timers.
|
||||
// For some reason `jest.clearAllTimers` also resets the simulated time, so preserve that
|
||||
const time = Date.now();
|
||||
jest.clearAllTimers();
|
||||
jest.setSystemTime(time);
|
||||
expect(checkSessionLockFree()).toBe(false);
|
||||
|
||||
// time advances a bit more
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(checkSessionLockFree()).toBe(false);
|
||||
|
||||
// second instance tries to start. This should block for 25 more seconds
|
||||
const onNewInstance2 = jest.fn();
|
||||
let session2Result: boolean | undefined;
|
||||
getSessionLock(onNewInstance2).then((res) => {
|
||||
session2Result = res;
|
||||
});
|
||||
|
||||
// after another 24.5 seconds, we are still waiting
|
||||
jest.advanceTimersByTime(24500);
|
||||
expect(session2Result).toBe(undefined);
|
||||
expect(checkSessionLockFree()).toBe(false);
|
||||
|
||||
// another 500ms and we get the lock
|
||||
await jest.advanceTimersByTimeAsync(500);
|
||||
expect(session2Result).toBe(true);
|
||||
expect(checkSessionLockFree()).toBe(false); // still false, because the new session has claimed it
|
||||
|
||||
expect(onNewInstance1).not.toHaveBeenCalled();
|
||||
expect(onNewInstance2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("A second instance waits for the first to shut down", async () => {
|
||||
// first instance starts. Once it gets the shutdown signal, it will wait two seconds and then release the lock.
|
||||
await getSessionLock(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 2000, 0);
|
||||
}),
|
||||
);
|
||||
|
||||
// second instance tries to start, but should block
|
||||
const { window: window2, getSessionLock: getSessionLock2 } = buildNewContext();
|
||||
let session2Result: boolean | undefined;
|
||||
getSessionLock2(async () => {}).then((res) => {
|
||||
session2Result = res;
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
// should still be blocking
|
||||
expect(session2Result).toBe(undefined);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
// session 2 now gets the lock
|
||||
expect(session2Result).toBe(true);
|
||||
window2.close();
|
||||
});
|
||||
|
||||
it("If a third instance starts while we are waiting, we give up immediately", async () => {
|
||||
// first instance starts. It will never release the lock.
|
||||
await getSessionLock(() => new Promise(() => {}));
|
||||
|
||||
// first instance should ping the timer after 5 seconds
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
// second instance starts
|
||||
const { getSessionLock: getSessionLock2 } = buildNewContext();
|
||||
let session2Result: boolean | undefined;
|
||||
const onNewInstance2 = jest.fn();
|
||||
getSessionLock2(onNewInstance2).then((res) => {
|
||||
session2Result = res;
|
||||
});
|
||||
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
// should still be blocking
|
||||
expect(session2Result).toBe(undefined);
|
||||
|
||||
// third instance starts
|
||||
const { getSessionLock: getSessionLock3 } = buildNewContext();
|
||||
getSessionLock3(async () => {});
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
// session 2 should have given up
|
||||
expect(session2Result).toBe(false);
|
||||
expect(onNewInstance2).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("If two new instances start concurrently, only one wins", async () => {
|
||||
// first instance starts. Once it gets the shutdown signal, it will wait two seconds and then release the lock.
|
||||
await getSessionLock(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 2000, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// first instance should ping the timer after 5 seconds
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
// two new instances start at once
|
||||
const { getSessionLock: getSessionLock2 } = buildNewContext();
|
||||
let session2Result: boolean | undefined;
|
||||
getSessionLock2(async () => {}).then((res) => {
|
||||
session2Result = res;
|
||||
});
|
||||
|
||||
const { getSessionLock: getSessionLock3 } = buildNewContext();
|
||||
let session3Result: boolean | undefined;
|
||||
getSessionLock3(async () => {}).then((res) => {
|
||||
session3Result = res;
|
||||
});
|
||||
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
// session 3 still be blocking. Session 2 should have given up.
|
||||
expect(session2Result).toBe(false);
|
||||
expect(session3Result).toBe(undefined);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
// session 3 now gets the lock
|
||||
expect(session2Result).toBe(false);
|
||||
expect(session3Result).toBe(true);
|
||||
});
|
||||
|
||||
/** build a new Window in the same domain as the current one.
|
||||
*
|
||||
* We do this by constructing an iframe, which gets its own Window object.
|
||||
*/
|
||||
function createWindow() {
|
||||
const iframe = window.document.createElement("iframe");
|
||||
window.document.body.appendChild(iframe);
|
||||
const window2: any = iframe.contentWindow;
|
||||
|
||||
otherWindows.push(window2);
|
||||
|
||||
// make the new Window use the same jest fake timers as us
|
||||
for (const m of ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"]) {
|
||||
// @ts-ignore
|
||||
window2[m] = global[m];
|
||||
}
|
||||
return window2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate `getSessionLock` in a new context (ie, using a different global `window`).
|
||||
*
|
||||
* The new window will share the same fake timer impl as the current context.
|
||||
*
|
||||
* @returns the new window and (a wrapper for) getSessionLock in the new context.
|
||||
*/
|
||||
function buildNewContext(): {
|
||||
window: Window;
|
||||
getSessionLock: (onNewInstance: () => Promise<void>) => Promise<boolean>;
|
||||
} {
|
||||
const window2 = createWindow();
|
||||
|
||||
// import the dependencies of getSessionLock into the new context
|
||||
window2._uuid = require("uuid");
|
||||
window2._logger = require("matrix-js-sdk/src/logger");
|
||||
window2.SESSION_LOCK_CONSTANTS = SESSION_LOCK_CONSTANTS;
|
||||
|
||||
// now, define getSessionLock as a global
|
||||
window2.eval(String(getSessionLock));
|
||||
|
||||
// return a function that will call it
|
||||
function callGetSessionLock(onNewInstance: () => Promise<void>): Promise<boolean> {
|
||||
// import the callback into the context
|
||||
window2._getSessionLockCallback = onNewInstance;
|
||||
|
||||
// start the function
|
||||
try {
|
||||
return window2.eval(`getSessionLock(_getSessionLockCallback)`);
|
||||
} finally {
|
||||
// we can now clear the callback
|
||||
delete window2._getSessionLockCallback;
|
||||
}
|
||||
}
|
||||
|
||||
return { window: window2, getSessionLock: callGetSessionLock };
|
||||
}
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue