253 lines
		
	
	
		
			9.3 KiB
		
	
	
	
		
			TypeScript
		
	
	
			
		
		
	
	
			253 lines
		
	
	
		
			9.3 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";
 | 
						|
import { resetJsDomAfterEach } from "../test-utils";
 | 
						|
 | 
						|
describe("SessionLock", () => {
 | 
						|
    const otherWindows: Array<Window> = [];
 | 
						|
 | 
						|
    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<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 };
 | 
						|
    }
 | 
						|
});
 |