/* Copyright 2024 New Vector Ltd. Copyright 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { checkSessionLockFree, getSessionLock, SESSION_LOCK_CONSTANTS } from "../../../src/utils/SessionLock"; import { resetJsDomAfterEach } from "../../test-utils"; describe("SessionLock", () => { const otherWindows: Array = []; beforeEach(() => { jest.useFakeTimers({ now: 1000 }); }); afterEach(() => { // shut down other windows created by `createWindow` otherWindows.forEach((window) => window.close()); otherWindows.splice(0); }); resetJsDomAfterEach(); 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((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((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) => Promise; } { 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): Promise { // 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 }; } });