element-web/test/utils/SessionLock-test.ts
Richard van der Hoff d13b6e1b41
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
2023-08-22 14:10:47 +00:00

275 lines
10 KiB
TypeScript

/*
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 };
}
});