diff --git a/src/interfaces.ts b/src/interfaces.ts index 45de794e11..2c30ac792f 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -236,9 +236,14 @@ export type AppStateBroadcastMessage = | { type: AppStateBroadcastMessageType.syncVersions; payload: RunnableVersion[]; + } + | { + type: AppStateBroadcastMessageType.activeVersionsChanged; + payload?: never; }; export enum AppStateBroadcastMessageType { + activeVersionsChanged = 'activeVersionsChanged', isDownloadingAll = 'isDownloadingAll', syncVersions = 'syncVersions', } diff --git a/src/renderer/components/settings-electron.tsx b/src/renderer/components/settings-electron.tsx index 52eae7598e..9d47b61624 100644 --- a/src/renderer/components/settings-electron.tsx +++ b/src/renderer/components/settings-electron.tsx @@ -215,6 +215,7 @@ export const ElectronSettings = observer( ); } + private filterSection(): JSX.Element { const { appState } = this.props; return ( @@ -401,7 +402,7 @@ export const ElectronSettings = observer( break; } - if (version === appState.currentElectronVersion.version) { + if (appState.activeVersions.has(version)) { return ( = new Set(); + + private getVersionLockName(ver: string) { + return `${AppState.versionLockNamePrefix}${ver}`; + } + + /** + * Updates the Electron versions that are currently active in some window. + */ + private async updateActiveVersions(): Promise { + this.activeVersions = ((await navigator.locks.query()).held || []).reduce< + Set + >((acc, item) => { + if (item.name?.startsWith(AppState.versionLockNamePrefix)) { + acc.add(item.name.split(AppState.versionLockNamePrefix)[1]); + } + + return acc; + }, new Set()); + } + constructor(versions: RunnableVersion[]) { makeObservable(this, { Bisector: observable, @@ -233,6 +262,7 @@ export class AppState { addAcceleratorToBlock: action, addLocalVersion: action, addNewVersions: action, + activeVersions: observable, channelsToShow: observable, clearConsole: action, currentElectronVersion: computed, @@ -476,6 +506,12 @@ export class AppState { const { type, payload } = event.data; switch (type) { + case AppStateBroadcastMessageType.activeVersionsChanged: { + this.updateActiveVersions(); + + break; + } + case AppStateBroadcastMessageType.isDownloadingAll: { this.isDownloadingAll = payload; break; @@ -795,34 +831,53 @@ export class AppState { public async removeVersion(ver: RunnableVersion): Promise { const { version, state, source } = ver; - if (ver === this.currentElectronVersion) { + if (this.activeVersions.has(ver.version)) { console.log(`State: Not removing active version ${version}`); return; } - console.log(`State: Removing Electron ${version}`); - if (source === VersionSource.local) { - if (version in this.versions) { - delete this.versions[version]; - saveLocalVersions(Object.values(this.versions)); - } else { - console.log(`State: Version ${version} already removed, doing nothing`); - } - } else { - if ( - state === InstallState.installed || - state == InstallState.downloaded - ) { - await this.installer.remove(version); - if (this.installer.state(version) === InstallState.missing) { - await window.ElectronFiddle.app.electronTypes.uncache(ver); + await navigator.locks.request( + this.getVersionLockName(version), + { + mode: 'exclusive', + ifAvailable: true, + }, + async (lock) => { + // another window is already removing this version + if (!lock) { + return; + } + + console.log(`State: Removing Electron ${version}`); - this.broadcastVersionStates([ver]); + if (source === VersionSource.local) { + if (version in this.versions) { + delete this.versions[version]; + saveLocalVersions(Object.values(this.versions)); + } else { + console.log( + `State: Version ${version} already removed, doing nothing`, + ); + } + } else { + if ( + state === InstallState.installed || + state == InstallState.downloaded + ) { + await this.installer.remove(version); + if (this.installer.state(version) === InstallState.missing) { + await window.ElectronFiddle.app.electronTypes.uncache(ver); + + this.broadcastVersionStates([ver]); + } + } else { + console.log( + `State: Version ${version} already removed, doing nothing`, + ); + } } - } else { - console.log(`State: Version ${version} already removed, doing nothing`); - } - } + }, + ); } /** @@ -954,10 +1009,45 @@ export class AppState { return; } + if (this.hasActiveLock) { + console.log(`Releasing lock on version ${this.version}`); + + // release the lock on the previous version + this.versionLockController.abort(); + + // replace the spent AbortController + this.versionLockController = new AbortController(); + } + const { version } = ver; console.log(`State: Switching to Electron ${version}`); this.version = version; + navigator.locks.request( + this.getVersionLockName(version), + { mode: 'shared' }, + (lock) => { + // let other windows know we're using this version + this.broadcastChannel.postMessage({ + type: AppStateBroadcastMessageType.activeVersionsChanged, + }); + + // the current window's state also needs an update - that's how + // the current window knows it can't remove this version + this.updateActiveVersions(); + + this.hasActiveLock = Boolean(lock); + + /** + * The lock is released when this promise resolves, so we keep it in the + * pending state until our AbortController is aborted. + */ + return new Promise((resolve) => { + this.versionLockController.signal.onabort = () => resolve(); + }); + }, + ); + // If there's no current fiddle, // or if the current fiddle is the previous version's template, // then load the new version's template. @@ -973,8 +1063,17 @@ export class AppState { } } - // Fetch new binaries, maybe? - await this.downloadVersion(ver); + await navigator.locks.request( + `downloading:${version}`, + { mode: 'exclusive' }, + async (lock) => { + console.log(`exclusive download lock granted:`); + console.log(lock); + + // Fetch new binaries, maybe? + await this.downloadVersion(ver); + }, + ); } /** diff --git a/tests/mocks/state.ts b/tests/mocks/state.ts index 5fb1f5ea2c..cccb47856c 100644 --- a/tests/mocks/state.ts +++ b/tests/mocks/state.ts @@ -17,6 +17,7 @@ import { objectDifference } from '../utils'; export class StateMock { public acceleratorsToBlock: BlockableAccelerator[] = []; public activeGistAction = GistActionState.none; + public activeVersions = new Set(); public channelsToShow: ElectronReleaseChannel[] = []; public editorMosaic = new EditorMosaic(); public environmentVariables: string[] = []; @@ -85,6 +86,7 @@ export class StateMock { public setVersion = jest.fn().mockImplementation((version: string) => { this.currentElectronVersion = this.versions[version]; this.version = version; + this.activeVersions.add(version); }); public isVersionUsable = jest.fn().mockImplementation(() => { return { ver: this.currentElectronVersion }; @@ -120,6 +122,7 @@ export class StateMock { makeObservable(this, { acceleratorsToBlock: observable, activeGistAction: observable, + activeVersions: observable, channelsToShow: observable, editorMosaic: observable, environmentVariables: observable, diff --git a/tests/renderer/components/settings-electron-spec.tsx b/tests/renderer/components/settings-electron-spec.tsx index 254c13e822..bc688b1a80 100644 --- a/tests/renderer/components/settings-electron-spec.tsx +++ b/tests/renderer/components/settings-electron-spec.tsx @@ -13,6 +13,7 @@ import { ElectronSettings } from '../../../src/renderer/components/settings-elec import { AppState } from '../../../src/renderer/state'; import { disableDownload } from '../../../src/renderer/utils/disable-download'; import { AppMock, StateMock, VersionsMock } from '../../mocks/mocks'; +import { waitFor } from '../../utils'; jest.mock('../../../src/renderer/utils/disable-download.ts'); @@ -20,12 +21,13 @@ describe('ElectronSettings component', () => { let store: StateMock; let mockVersions: Record; let mockVersionsArray: RunnableVersion[]; + const version = '2.0.1'; beforeEach(() => { ({ mockVersions, mockVersionsArray } = new VersionsMock()); ({ state: store } = window.ElectronFiddle.app as unknown as AppMock); - store.initVersions('2.0.1', { ...mockVersions }); + store.initVersions(version, { ...mockVersions }); store.channelsToShow = [ ElectronReleaseChannel.stable, ElectronReleaseChannel.beta, @@ -39,7 +41,7 @@ describe('ElectronSettings component', () => { store.versionsToShow[i++].state = InstallState.installing; }); - it('renders', () => { + it('renders', async () => { const spy = jest .spyOn(window.ElectronFiddle, 'getOldestSupportedMajor') .mockReturnValue(9); @@ -65,6 +67,10 @@ describe('ElectronSettings component', () => { const wrapper = shallow( , ); + + await store.setVersion(version); + await waitFor(() => store.activeVersions.size > 0); + expect(wrapper).toMatchSnapshot(); spy.mockRestore(); diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts index ca8467e55b..6968c41c73 100644 --- a/tests/renderer/state-spec.ts +++ b/tests/renderer/state-spec.ts @@ -21,7 +21,11 @@ import { saveLocalVersions, } from '../../src/renderer/versions'; import { VersionsMock, createEditorValues } from '../mocks/mocks'; -import { overrideRendererPlatform, resetRendererPlatform } from '../utils'; +import { + overrideRendererPlatform, + resetRendererPlatform, + waitFor, +} from '../utils'; jest.mock('../../src/renderer/versions', () => { const { getReleaseChannel } = jest.requireActual( @@ -311,6 +315,10 @@ describe('AppState', () => { it('does not remove the active version', async () => { const ver = appState.versions[active]; + + await appState.setVersion(ver.version); + await waitFor(() => appState.activeVersions.size > 0); + broadcastMessageSpy.mockClear(); await appState.removeVersion(ver); expect(removeSpy).not.toHaveBeenCalled(); diff --git a/tests/setup.ts b/tests/setup.ts index 0377efb039..7d9d6ccbd2 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -5,6 +5,7 @@ import { mocked } from 'jest-mock'; import { configure as mobxConfigure } from 'mobx'; import { ElectronFiddleMock } from './mocks/mocks'; +import { getOrCreateMapValue } from './utils'; enzymeConfigure({ adapter: new Adapter() }); @@ -76,6 +77,164 @@ delete (window as any).localStorage; window.navigator = window.navigator ?? {}; (window.navigator.clipboard as any) = {}; +type MockLock = Lock & { + abortController: AbortController; +}; + +class FakeNavigatorLocks implements LockManager { + locks = { + held: new Map>>(), + }; + + query = async () => { + const result = { + held: [...this.locks.held.values()].reduce((acc, item) => { + acc.push(...[...item.get('exclusive')!.values()]); + acc.push(...[...item.get('shared')!.values()]); + + return acc; + }, [] as MockLock[]), + }; + + return result as LockManagerSnapshot; + }; + + request = (async (...args: Parameters) => { + const [ + name, + options = { + ifAvailable: false, + mode: 'exclusive', + steal: false, + }, + cb, + ] = args; + + const { ifAvailable, mode, steal } = options; + + const lock = { + name, + mode, + abortController: new AbortController(), + } as MockLock; + + const heldLocksWithSameName = getOrCreateMapValue( + this.locks.held, + name, + new Map>(), + ); + + const exclusiveLocksWithSameName = getOrCreateMapValue( + heldLocksWithSameName, + 'exclusive', + new Set(), + ); + + const sharedLocksWithSameName = getOrCreateMapValue( + heldLocksWithSameName, + 'shared', + new Set(), + ); + + if (mode === 'shared') { + sharedLocksWithSameName.add(lock); + + try { + await cb(lock); + } finally { + sharedLocksWithSameName.delete(lock); + } + + return; + } + + // exclusive lock + + // no locks with this name -> grant an exclusive lock + if ( + exclusiveLocksWithSameName.size === 0 && + sharedLocksWithSameName.size === 0 + ) { + exclusiveLocksWithSameName.add(lock); + + try { + await cb(lock); + } finally { + exclusiveLocksWithSameName.delete(lock); + } + + return; + } + + // steal any currently held locks + if (steal) { + for (const lock of sharedLocksWithSameName) { + lock.abortController.abort(); + } + + for (const lock of exclusiveLocksWithSameName) { + lock.abortController.abort(); + } + + sharedLocksWithSameName.clear(); + exclusiveLocksWithSameName.clear(); + + exclusiveLocksWithSameName.add(lock); + + try { + await cb(lock); + } finally { + exclusiveLocksWithSameName.delete(lock); + } + + return; + } + + // run the callback without waiting for the lock to be released + if (ifAvailable) { + // just run the callback without waiting for it + cb(null); + + return; + } + + // @TODO add the lock to the list of pending locks? + + // it's an exclusive lock, so there's only one value + const currentLock = exclusiveLocksWithSameName.values().next() + .value as MockLock; + + const { abortController: currentLockAbortController } = currentLock; + + // wait for the current lock to be released + await new Promise((resolve, reject) => { + currentLockAbortController.signal.onabort = () => resolve(); + + const { abortController: pendingLockAbortController } = lock; + + // this allows the locking mechanism to release this lock + pendingLockAbortController.signal.onabort = () => reject(); + }); + + // clear the exclusive locks + exclusiveLocksWithSameName.clear(); + + // grant our lock + exclusiveLocksWithSameName.add(lock); + + try { + // run the callback + await cb(lock); + } finally { + exclusiveLocksWithSameName.delete(lock); + } + + return; + }) as LockManager['request']; +} + +(window.navigator.locks as any) = new FakeNavigatorLocks(); + /** * Mock these properties twice so that they're available * both at the top-level of files and also within the diff --git a/tests/utils.ts b/tests/utils.ts index 4aa309cc23..20ec9029b0 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -147,3 +147,25 @@ export function emitEvent(type: FiddleEvent, ...args: any[]) { }, ); } + +export function getOrCreateMapValue>( + map: T, + key: MapKey, + fallbackValue: MapValue, +): MapValue { + if (!map.has(key)) { + map.set(key, fallbackValue); + + return fallbackValue; + } + + return map.get(key) as MapValue; +} + +type MapKey> = T extends Map + ? I + : never; + +type MapValue> = T extends Map + ? I + : never;