From e952bf6b2a0f4da9dbe5bbe738c4fef7402d5094 Mon Sep 17 00:00:00 2001 From: Florent Benoit Date: Mon, 7 Aug 2023 10:54:46 +0200 Subject: [PATCH] fix: add maximum activation time for extensions after reaching that time, loader continue without waiting for this extension extension is flagged as a failed extension fixes https://github.com/containers/podman-desktop/issues/3280 fixes https://github.com/containers/podman-desktop/issues/2993 Signed-off-by: Florent Benoit --- .../src/plugin/extension-loader-settings.ts | 22 ++++++++ .../main/src/plugin/extension-loader.spec.ts | 44 +++++++++++++++- packages/main/src/plugin/extension-loader.ts | 51 +++++++++++++++++-- 3 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 packages/main/src/plugin/extension-loader-settings.ts diff --git a/packages/main/src/plugin/extension-loader-settings.ts b/packages/main/src/plugin/extension-loader-settings.ts new file mode 100644 index 0000000000000..7d43fcb540e21 --- /dev/null +++ b/packages/main/src/plugin/extension-loader-settings.ts @@ -0,0 +1,22 @@ +/********************************************************************** + * Copyright (C) 2023 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export enum ExtensionLoaderSettings { + SectionName = 'extensions', + MaxActivationTime = 'maxActivationTime', +} diff --git a/packages/main/src/plugin/extension-loader.spec.ts b/packages/main/src/plugin/extension-loader.spec.ts index 418442c7303b5..e00429c7ace16 100644 --- a/packages/main/src/plugin/extension-loader.spec.ts +++ b/packages/main/src/plugin/extension-loader.spec.ts @@ -66,6 +66,10 @@ class TestExtensionLoader extends ExtensionLoader { return this.extensionState; } + getExtensionStateErrors() { + return this.extensionStateErrors; + } + doRequire(module: string): NodeRequire { return super.doRequire(module); } @@ -79,7 +83,10 @@ const menuRegistry: MenuRegistry = {} as unknown as MenuRegistry; const providerRegistry: ProviderRegistry = {} as unknown as ProviderRegistry; -const configurationRegistry: ConfigurationRegistry = {} as unknown as ConfigurationRegistry; +const configurationRegistryGetConfigurationMock = vi.fn(); +const configurationRegistry: ConfigurationRegistry = { + getConfiguration: configurationRegistryGetConfigurationMock, +} as unknown as ConfigurationRegistry; const imageRegistry: ImageRegistry = {} as unknown as ImageRegistry; @@ -292,6 +299,41 @@ test('Verify extension error leads to failed state', async () => { expect(extensionLoader.getExtensionState().get(id)).toBe('failed'); }); +test('Verify extension activate with a long timeout is flagged as error', async () => { + const id = 'extension.id'; + + // mock getConfiguration + const getMock = vi.fn(); + configurationRegistryGetConfigurationMock.mockReturnValue({ + get: getMock, + }); + getMock.mockReturnValue(1); + + await extensionLoader.activateExtension( + { + id: id, + name: 'id', + path: 'dummy', + api: {} as typeof containerDesktopAPI, + mainPath: '', + removable: false, + manifest: {}, + }, + { + activate: () => { + // wait for 20 seconds + return new Promise(resolve => setTimeout(resolve, 20000)); + }, + }, + ); + + expect(extensionLoader.getExtensionStateErrors().get(id)).toBeDefined(); + expect(extensionLoader.getExtensionStateErrors().get(id)?.toString()).toContain( + 'Extension extension.id activation timed out after 1 seconds', + ); + expect(extensionLoader.getExtensionState().get(id)).toBe('failed'); +}); + test('Verify setExtensionsUpdates', async () => { // set the private field analyzedExtensions of extensionLoader const analyzedExtensions = new Map(); diff --git a/packages/main/src/plugin/extension-loader.ts b/packages/main/src/plugin/extension-loader.ts index 22b1ef83a93e0..5f742fb548253 100644 --- a/packages/main/src/plugin/extension-loader.ts +++ b/packages/main/src/plugin/extension-loader.ts @@ -25,7 +25,7 @@ import * as zipper from 'zip-local'; import type { TrayMenuRegistry } from './tray-menu-registry.js'; import { Disposable } from './types/disposable.js'; import type { ProviderRegistry } from './provider-registry.js'; -import type { ConfigurationRegistry } from './configuration-registry.js'; +import type { ConfigurationRegistry, IConfigurationNode } from './configuration-registry.js'; import type { ImageRegistry } from './image-registry.js'; import type { MessageBox } from './message-box.js'; import type { ProgressImpl } from './progress-impl.js'; @@ -65,6 +65,7 @@ import type { Context } from './context/context.js'; import type { OnboardingRegistry } from './onboarding-registry.js'; import { createHttpPatchedModules } from './proxy-resolver.js'; import { ModuleLoader } from './module-loader.js'; +import { ExtensionLoaderSettings } from './extension-loader-settings.js'; /** * Handle the loading of an extension @@ -212,6 +213,23 @@ export class ExtensionLoader { this.moduleLoader.addOverride({ '@podman-desktop/api': ext => ext.api }); // add podman desktop API this.moduleLoader.overrideRequire(); + // register configuration for the max activation time + const maxActivationTimeConfiguration: IConfigurationNode = { + id: 'preferences.extension', + title: 'Extensions', + type: 'object', + properties: { + [ExtensionLoaderSettings.SectionName + '.' + ExtensionLoaderSettings.MaxActivationTime]: { + description: 'Maximum activation time for an extension, in seconds.', + type: 'number', + default: 5, + minimum: 1, + maximum: 100, + }, + }, + }; + + this.configurationRegistry.registerConfigurations([maxActivationTimeConfiguration]); } protected async setupScanningDirectory(): Promise { @@ -1045,10 +1063,35 @@ export class ExtensionLoader { const telemetryOptions = { extensionId: extension.id }; try { if (typeof extensionMain?.['activate'] === 'function') { + // maximum time to wait for the extension to activate by reading from configuration + const delayInSeconds: number = + this.configurationRegistry + .getConfiguration(ExtensionLoaderSettings.SectionName) + .get(ExtensionLoaderSettings.MaxActivationTime) || 5; + const delayInMilliseconds = delayInSeconds * 1000; + + // reject a promise after this delay + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Extension ${extension.id} activation timed out after ${delayInSeconds} seconds`)), + delayInMilliseconds, + ), + ); + // it returns exports - console.log(`Activating extension (${extension.id})`); - await extensionMain['activate'].apply(undefined, [extensionContext]); - console.log(`Activating extension (${extension.id}) ended`); + console.log(`Activating extension (${extension.id}) with max activation time of ${delayInSeconds} seconds`); + + const beforeActivateTime = performance.now(); + const activatePromise = extensionMain['activate'].apply(undefined, [extensionContext]); + + // if extension reach the timeout, do not wait for it to finish and flag as error + await Promise.race([activatePromise, timeoutPromise]); + const afterActivateTime = performance.now(); + console.log( + `Activating extension (${extension.id}) ended in ${Math.round( + afterActivateTime - beforeActivateTime, + )} milliseconds`, + ); } const id = extension.id; const activatedExtension: ActivatedExtension = {