diff --git a/packages/snaps-controllers/CHANGELOG.md b/packages/snaps-controllers/CHANGELOG.md index af67fef689..cc6ae41682 100644 --- a/packages/snaps-controllers/CHANGELOG.md +++ b/packages/snaps-controllers/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** All action types were renamed from `DoSomething` to `ControllerNameDoSomethingAction` ([#3907](https://github.com/MetaMask/snaps/pull/3907), [#3911](https://github.com/MetaMask/snaps/pull/3911), [#3912](https://github.com/MetaMask/snaps/pull/3912)) +- **BREAKING:** All action types were renamed from `DoSomething` to `ControllerNameDoSomethingAction` ([#3907](https://github.com/MetaMask/snaps/pull/3907), [#3911](https://github.com/MetaMask/snaps/pull/3911), [#3912](https://github.com/MetaMask/snaps/pull/3912), [#3916](https://github.com/MetaMask/snaps/pull/3916)) - `SnapController` actions: - `GetSnap` is now `SnapControllerGetSnapAction`. - Note: The method is now called `getSnap` instead of `get`. @@ -51,27 +51,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `DeleteInterface` is now `SnapInterfaceControllerDeleteInterfaceAction`. - `UpdateInterfaceState` is now `SnapInterfaceControllerUpdateInterfaceStateAction`. - `ResolveInterface` is now `SnapInterfaceControllerResolveInterfaceAction`. -- **BREAKING:** All `SnapController` event types were renamed from `OnSomething` to `SnapControllerOnSomethingEvent` ([#3907](https://github.com/MetaMask/snaps/pull/3907)) - - `SnapStateChange` was removed in favour of `SnapControllerStateChangeEvent`. - - `SnapBlocked` is now `SnapControllerSnapBlockedEvent`. - - `SnapInstallStarted` is now `SnapControllerSnapInstallStartedEvent`. - - `SnapInstallFailed` is now `SnapControllerSnapInstallFailedEvent`. - - `SnapInstalled` is now `SnapControllerSnapInstalledEvent`. - - `SnapUninstalled` is now `SnapControllerSnapUninstalledEvent`. - - `SnapUnblocked` is now `SnapControllerSnapUnblockedEvent. - - `SnapUpdated` is now `SnapControllerSnapUpdatedEvent`. - - `SnapRolledback` is now `SnapControllerSnapRolledbackEvent`. - - `SnapTerminated` is now `SnapControllerSnapTerminatedEvent`. - - `SnapEnabled` is now `SnapControllerSnapEnabledEvent`. - - `SnapDisabled` is now `SnapControllerSnapDisabledEvent`. + - `ExecutionService` actions: + - `ExecuteSnap` is now `ExecutionServiceExecuteSnapAction`. + - `HandleRequest` is now `ExecutionServiceHandleRequestAction`. + - `TerminateSnap` is now `ExecutionServiceTerminateSnapAction`. + - `GetExecutionStatus` is now `ExecutionServiceGetExecutionStatusAction`. +- **BREAKING:** All event types were renamed from `OnSomething` to `ControllerOnSomethingEvent` ([#3907](https://github.com/MetaMask/snaps/pull/3907), [#3916](https://github.com/MetaMask/snaps/pull/3916)) + - `SnapController` events: + - `SnapStateChange` was removed in favour of `SnapControllerStateChangeEvent`. + - `SnapBlocked` is now `SnapControllerSnapBlockedEvent`. + - `SnapInstallStarted` is now `SnapControllerSnapInstallStartedEvent`. + - `SnapInstallFailed` is now `SnapControllerSnapInstallFailedEvent`. + - `SnapInstalled` is now `SnapControllerSnapInstalledEvent`. + - `SnapUninstalled` is now `SnapControllerSnapUninstalledEvent`. + - `SnapUnblocked` is now `SnapControllerSnapUnblockedEvent`. + - `SnapUpdated` is now `SnapControllerSnapUpdatedEvent`. + - `SnapRolledback` is now `SnapControllerSnapRolledbackEvent`. + - `SnapTerminated` is now `SnapControllerSnapTerminatedEvent`. + - `SnapEnabled` is now `SnapControllerSnapEnabledEvent`. + - `SnapDisabled` is now `SnapControllerSnapDisabledEvent`. + - `ExecutionService` events: + - `ErrorMessageEvent` is now `ExecutionServiceUnhandledErrorEvent`. + - `OutboundRequest` is now `ExecutionServiceOutboundRequestEvent`. + - `OutboundResponse` is now `ExecutionServiceOutboundResponseEvent`. - **BREAKING:**: Rename `MultichainRouter` to `MultichainRoutingService` and update action types accordingly ([#3913](https://github.com/MetaMask/snaps/pull/3913)) - This is consistent with the naming of other services. - **BREAKING:** `MultichainRoutingService` now requires `SnapController:getRunnableSnaps` instead of `SnapController:getAllSnaps` ([#3913](https://github.com/MetaMask/snaps/pull/3913)) - **BREAKING:** `SnapInsightsController` now requires `SnapController:getRunnableSnaps` instead of `SnapController:getAllSnaps` ([#3915](https://github.com/MetaMask/snaps/pull/3915)) +- **RREAKING:** Replace `ExecutionService` interface with abstract class ([#3916](https://github.com/MetaMask/snaps/pull/3916)) + - The `ExecutionService` is now an abstract class and replaces the previous `AbstractExecutionService` class interface. ### Removed -- **BREAKING:** `incrementActiveReferences` and `decrementActiveReferences` actions were removed ([#3907](https://github.com/MetaMask/snaps/pull/3907)) +- **RREAKING:** Remove `AbstractExecutionService` class in favour of `ExecutionService` ([#3916](https://github.com/MetaMask/snaps/pull/3916)) +- **BREAKING:** Remove `incrementActiveReferences` and `decrementActiveReferences` actions ([#3907](https://github.com/MetaMask/snaps/pull/3907)) ## [18.0.4] diff --git a/packages/snaps-controllers/src/services/AbstractExecutionService.ts b/packages/snaps-controllers/src/services/AbstractExecutionService.ts deleted file mode 100644 index 4e8acf59f8..0000000000 --- a/packages/snaps-controllers/src/services/AbstractExecutionService.ts +++ /dev/null @@ -1,524 +0,0 @@ -import { asV2Middleware } from '@metamask/json-rpc-engine'; -import { JsonRpcEngineV2 as JsonRpcEngine } from '@metamask/json-rpc-engine/v2'; -import { createStreamMiddleware } from '@metamask/json-rpc-middleware-stream'; -import ObjectMultiplex from '@metamask/object-multiplex'; -import type { BasePostMessageStream } from '@metamask/post-message-stream'; -import type { SnapRpcHookArgs } from '@metamask/snaps-utils'; -import { SNAP_STREAM_NAMES, logError, logWarning } from '@metamask/snaps-utils'; -import type { - Json, - JsonRpcNotification, - JsonRpcRequest, -} from '@metamask/utils'; -import { - Duration, - assertIsJsonRpcRequest, - hasProperty, - inMilliseconds, -} from '@metamask/utils'; -import { nanoid } from 'nanoid'; -import { pipeline } from 'readable-stream'; -import type { Duplex } from 'readable-stream'; - -import type { - ExecutionService, - ExecutionServiceMessenger, - SnapErrorJson, - SnapExecutionData, -} from './ExecutionService'; -import { log } from '../logging'; -import { Timer } from '../snaps/Timer'; -import { hasTimedOut, withTimeout } from '../utils'; - -const controllerName = 'ExecutionService'; - -export type SetupSnapProvider = (snapId: string, stream: Duplex) => void; - -export type ExecutionServiceArgs = { - setupSnapProvider: SetupSnapProvider; - messenger: ExecutionServiceMessenger; - initTimeout?: number; - pingTimeout?: number; - terminationTimeout?: number; - usePing?: boolean; -}; - -export type JobStreams = { - command: Duplex; - rpc: Duplex; - connection: BasePostMessageStream; - mux: ObjectMultiplex; -}; - -export type Job = { - id: string; - streams: JobStreams; - rpcEngine: JsonRpcEngine; - worker: WorkerType; -}; - -export type TerminateJobArgs = Partial> & - Pick, 'id'>; - -/** - Statuses used for diagnostic purposes - - created: The initial state, no initialization has started - - initializing: Snap execution environment is initializing - - initialized: Snap execution environment has initialized - - executing: Snap source code is being executed - - running: Snap executed and ready for RPC requests - */ -type ExecutionStatus = - | 'created' - | 'initializing' - | 'initialized' - | 'executing' - | 'running'; - -export abstract class AbstractExecutionService - implements ExecutionService -{ - name: typeof controllerName = controllerName; - - state = null; - - readonly #jobs: Map>; - - readonly #status: Map; - - readonly #setupSnapProvider: SetupSnapProvider; - - readonly #messenger: ExecutionServiceMessenger; - - readonly #initTimeout: number; - - readonly #pingTimeout: number; - - readonly #terminationTimeout: number; - - readonly #usePing: boolean; - - constructor({ - setupSnapProvider, - messenger, - initTimeout = inMilliseconds(60, Duration.Second), - pingTimeout = inMilliseconds(10, Duration.Second), - terminationTimeout = inMilliseconds(1, Duration.Second), - usePing = true, - }: ExecutionServiceArgs) { - this.#jobs = new Map(); - this.#status = new Map(); - this.#setupSnapProvider = setupSnapProvider; - this.#messenger = messenger; - this.#initTimeout = initTimeout; - this.#pingTimeout = pingTimeout; - this.#terminationTimeout = terminationTimeout; - this.#usePing = usePing; - - this.#registerMessageHandlers(); - } - - /** - * Constructor helper for registering the controller's messaging system - * actions. - */ - #registerMessageHandlers(): void { - this.#messenger.registerActionHandler( - `${controllerName}:handleRpcRequest`, - async (snapId: string, options: SnapRpcHookArgs) => - this.handleRpcRequest(snapId, options), - ); - - this.#messenger.registerActionHandler( - `${controllerName}:executeSnap`, - async (data: SnapExecutionData) => this.executeSnap(data), - ); - - this.#messenger.registerActionHandler( - `${controllerName}:terminateSnap`, - async (snapId: string) => this.terminateSnap(snapId), - ); - - this.#messenger.registerActionHandler( - `${controllerName}:terminateAllSnaps`, - async () => this.terminateAllSnaps(), - ); - } - - /** - * Performs additional necessary work during job termination. **MUST** be - * implemented by concrete implementations. See - * {@link AbstractExecutionService.terminate} for details. - * - * @param job - The object corresponding to the job to be terminated. - */ - protected abstract terminateJob( - job: TerminateJobArgs, - ): Promise; - - /** - * Terminates the Snap with the specified ID and deletes all its associated - * data. Any subsequent messages targeting the Snap will fail with an error. - * Throws an error if termination fails unexpectedly. - * - * @param snapId - The id of the Snap to be terminated. - */ - public async terminateSnap(snapId: string): Promise { - const job = this.#jobs.get(snapId); - if (!job) { - return; - } - - try { - // Ping worker and tell it to run teardown, continue with termination if it takes too long - const result = await withTimeout( - this.#command(snapId, { - jsonrpc: '2.0', - method: 'terminate', - id: nanoid(), - }), - this.#terminationTimeout, - ); - - if (result === hasTimedOut || result !== 'OK') { - logWarning(`Snap "${snapId}" failed to terminate gracefully.`); - } - } catch { - // Ignore - } - - Object.values(job.streams).forEach((stream) => { - try { - if (!stream.destroyed) { - stream.destroy(); - } - } catch (error) { - logError('Error while destroying stream', error); - } - }); - - await this.terminateJob(job); - - this.#jobs.delete(snapId); - this.#status.delete(snapId); - log(`Snap "${snapId}" terminated.`); - } - - /** - * Initiates a job for a Snap. - * - * @param snapId - The ID of the Snap to initiate a job for. - * @param timer - The timer to use for timeouts. - * @returns Information regarding the created job. - * @throws If the execution service returns an error or execution times out. - */ - async #initJob(snapId: string, timer: Timer): Promise> { - const { streams, worker } = await this.#initStreams(snapId, timer); - - const jsonRpcConnection = createStreamMiddleware(); - - pipeline( - jsonRpcConnection.stream, - streams.command, - jsonRpcConnection.stream, - (error) => { - if (error && !error.message?.match('Premature close')) { - logError(`Command stream failure.`, error); - } - }, - ); - - const rpcEngine = JsonRpcEngine.create({ - middleware: [asV2Middleware(jsonRpcConnection.middleware)], - }); - - const envMetadata = { - id: snapId, - streams, - rpcEngine, - worker, - }; - this.#jobs.set(snapId, envMetadata); - - return envMetadata; - } - - /** - * Sets up the streams for an initiated job. - * - * @param snapId - The Snap ID. - * @param timer - The timer to use for timeouts. - * @returns The streams to communicate with the worker and the worker itself. - * @throws If the execution service returns an error or execution times out. - */ - async #initStreams( - snapId: string, - timer: Timer, - ): Promise<{ streams: JobStreams; worker: WorkerType }> { - const result = await withTimeout(this.initEnvStream(snapId), timer); - - if (result === hasTimedOut) { - // For certain environments, such as the iframe we may have already created the worker and wish to terminate it. - await this.terminateJob({ id: snapId }); - - const status = this.#status.get(snapId); - if (status === 'created') { - // Currently this error can only be thrown by OffscreenExecutionService. - throw new Error( - `The executor for "${snapId}" couldn't start initialization. The offscreen document may not exist.`, - ); - } - throw new Error( - `The executor for "${snapId}" failed to initialize. The iframe/webview/worker failed to load.`, - ); - } - - const { worker, stream: envStream } = result; - const mux = setupMultiplex(envStream, `Snap: "${snapId}"`); - const commandStream = mux.createStream(SNAP_STREAM_NAMES.COMMAND); - - // Handle out-of-band errors, i.e. errors thrown from the Snap outside of the req/res cycle. - // Also keep track of outbound request/responses - const notificationHandler = ( - message: - | JsonRpcRequest - | JsonRpcNotification>, - ) => { - if (hasProperty(message, 'id')) { - return; - } - - if (message.method === 'OutboundRequest') { - this.#messenger.publish('ExecutionService:outboundRequest', snapId); - } else if (message.method === 'OutboundResponse') { - this.#messenger.publish('ExecutionService:outboundResponse', snapId); - } else if (message.method === 'UnhandledError') { - this.#messenger.publish( - 'ExecutionService:unhandledError', - snapId, - (message.params as { error: SnapErrorJson }).error, - ); - commandStream.removeListener('data', notificationHandler); - } else { - logError( - new Error( - `Received unexpected command stream notification "${message.method}".`, - ), - ); - } - }; - - commandStream.on('data', notificationHandler); - - const rpcStream = mux - .createStream(SNAP_STREAM_NAMES.JSON_RPC) - .setMaxListeners(20); - - rpcStream.on('data', (chunk) => { - if (chunk?.data && hasProperty(chunk?.data, 'id')) { - this.#messenger.publish('ExecutionService:outboundRequest', snapId); - } - }); - - // An error handler is not attached to the RPC stream until `setupSnapProvider` is called. - // We must set it up here to prevent errors from bubbling up if the stream is destroyed before then. - rpcStream.on('error', (error) => { - if (error && !error.message?.match('Premature close')) { - logError(`Snap: "${snapId}" - RPC stream failure:`, error); - } - }); - - const originalWrite = rpcStream.write.bind(rpcStream); - - // @ts-expect-error Hack to inspect the messages being written to the stream. - rpcStream.write = (chunk, encoding, callback) => { - // Ignore chain switching notifications as it doesn't matter for the SnapProvider. - if (chunk?.data?.method === 'metamask_chainChanged') { - return true; - } - - if (chunk?.data && hasProperty(chunk?.data, 'id')) { - this.#messenger.publish('ExecutionService:outboundResponse', snapId); - } - - return originalWrite(chunk, encoding, callback); - }; - - return { - streams: { - command: commandStream, - rpc: rpcStream, - connection: envStream, - mux, - }, - worker, - }; - } - - /** - * Abstract function implemented by implementing class that spins up a new worker for a job. - * - * Depending on the execution environment, this may run forever if the Snap fails to start up properly, therefore any call to this function should be wrapped in a timeout. - */ - protected abstract initEnvStream(snapId: string): Promise<{ - worker: WorkerType; - stream: BasePostMessageStream; - }>; - - /** - * Set the execution status of the Snap. - * - * @param snapId - The Snap ID. - * @param status - The current execution status. - */ - protected setSnapStatus(snapId: string, status: ExecutionStatus) { - this.#status.set(snapId, status); - } - - async terminateAllSnaps() { - await Promise.all( - [...this.#jobs.keys()].map(async (snapId) => this.terminateSnap(snapId)), - ); - } - - /** - * Initializes and executes a Snap, setting up the communication channels to the Snap etc. - * - * @param snapData - Data needed for Snap execution. - * @param snapData.snapId - The ID of the Snap to execute. - * @param snapData.sourceCode - The source code of the Snap to execute. - * @param snapData.endowments - The endowments available to the executing Snap. - * @returns A string `OK` if execution succeeded. - * @throws If the execution service returns an error or execution times out. - */ - async executeSnap({ - snapId, - sourceCode, - endowments, - }: SnapExecutionData): Promise { - if (this.#jobs.has(snapId)) { - throw new Error(`"${snapId}" is already running.`); - } - - this.setSnapStatus(snapId, 'created'); - - const timer = new Timer(this.#initTimeout); - - // This may resolve even if the environment has failed to start up fully - const job = await this.#initJob(snapId, timer); - - // Certain environments use ping as part of their initialization and thus can skip it here - if (this.#usePing) { - // Ping the worker to ensure that it started up - const pingResult = await withTimeout( - this.#command(job.id, { - jsonrpc: '2.0', - method: 'ping', - id: nanoid(), - }), - this.#pingTimeout, - ); - - if (pingResult === hasTimedOut) { - throw new Error( - `The executor for "${snapId}" was unreachable. The executor did not respond in time.`, - ); - } - } - - const rpcStream = job.streams.rpc; - - this.#setupSnapProvider(snapId, rpcStream); - - // Use the remaining time as the timer, but ensure that the - // Snap gets at least half the init timeout. - const remainingTime = Math.max(timer.remaining, this.#initTimeout / 2); - - this.setSnapStatus(snapId, 'initialized'); - - const request = { - jsonrpc: '2.0', - method: 'executeSnap', - params: { snapId, sourceCode, endowments }, - id: nanoid(), - }; - - assertIsJsonRpcRequest(request); - - this.setSnapStatus(snapId, 'executing'); - - const result = await withTimeout( - this.#command(job.id, request), - remainingTime, - ); - - if (result === hasTimedOut) { - throw new Error(`${snapId} failed to start.`); - } - - if (result === 'OK') { - this.setSnapStatus(snapId, 'running'); - } - - return result as string; - } - - async #command( - snapId: string, - message: JsonRpcRequest, - ): Promise { - const job = this.#jobs.get(snapId); - if (!job) { - throw new Error(`"${snapId}" is not currently running.`); - } - - log('Parent: Sending Command', message); - return await job.rpcEngine.handle(message); - } - - /** - * Handle RPC request. - * - * @param snapId - The ID of the recipient Snap. - * @param options - Bag of options to pass to the RPC handler. - * @returns Promise that can handle the request. - */ - public async handleRpcRequest( - snapId: string, - options: SnapRpcHookArgs, - ): Promise { - const { handler, request, origin } = options; - - return await this.#command(snapId, { - id: nanoid(), - jsonrpc: '2.0', - method: 'snapRpc', - params: { - snapId, - origin, - handler, - request: request as JsonRpcRequest, - }, - }); - } -} - -/** - * Sets up stream multiplexing for the given stream. - * - * @param connectionStream - The stream to mux. - * @param streamName - The name of the stream, for identification in errors. - * @returns The multiplexed stream. - */ -export function setupMultiplex( - connectionStream: Duplex, - streamName: string, -): ObjectMultiplex { - const mux = new ObjectMultiplex(); - pipeline(connectionStream, mux, connectionStream, (error) => { - if (error && !error.message?.match('Premature close')) { - logError(`"${streamName}" stream failure.`, error); - } - }); - return mux; -} diff --git a/packages/snaps-controllers/src/services/ExecutionService-method-action-types.ts b/packages/snaps-controllers/src/services/ExecutionService-method-action-types.ts new file mode 100644 index 0000000000..0993572ac4 --- /dev/null +++ b/packages/snaps-controllers/src/services/ExecutionService-method-action-types.ts @@ -0,0 +1,59 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { ExecutionService } from './ExecutionService'; + +/** + * Terminates the Snap with the specified ID and deletes all its associated + * data. Any subsequent messages targeting the Snap will fail with an error. + * Throws an error if termination fails unexpectedly. + * + * @param snapId - The id of the Snap to be terminated. + */ +export type ExecutionServiceTerminateSnapAction = { + type: `ExecutionService:terminateSnap`; + handler: ExecutionService['terminateSnap']; +}; + +export type ExecutionServiceTerminateAllSnapsAction = { + type: `ExecutionService:terminateAllSnaps`; + handler: ExecutionService['terminateAllSnaps']; +}; + +/** + * Initializes and executes a Snap, setting up the communication channels to the Snap etc. + * + * @param snapData - Data needed for Snap execution. + * @param snapData.snapId - The ID of the Snap to execute. + * @param snapData.sourceCode - The source code of the Snap to execute. + * @param snapData.endowments - The endowments available to the executing Snap. + * @returns A string `OK` if execution succeeded. + * @throws If the execution service returns an error or execution times out. + */ +export type ExecutionServiceExecuteSnapAction = { + type: `ExecutionService:executeSnap`; + handler: ExecutionService['executeSnap']; +}; + +/** + * Handle RPC request. + * + * @param snapId - The ID of the recipient Snap. + * @param options - Bag of options to pass to the RPC handler. + * @returns Promise that can handle the request. + */ +export type ExecutionServiceHandleRpcRequestAction = { + type: `ExecutionService:handleRpcRequest`; + handler: ExecutionService['handleRpcRequest']; +}; + +/** + * Union of all ExecutionService action types. + */ +export type ExecutionServiceMethodActions = + | ExecutionServiceTerminateSnapAction + | ExecutionServiceTerminateAllSnapsAction + | ExecutionServiceExecuteSnapAction + | ExecutionServiceHandleRpcRequestAction; diff --git a/packages/snaps-controllers/src/services/AbstractExecutionService.test.ts b/packages/snaps-controllers/src/services/ExecutionService.test.ts similarity index 97% rename from packages/snaps-controllers/src/services/AbstractExecutionService.test.ts rename to packages/snaps-controllers/src/services/ExecutionService.test.ts index 342df5dd33..6bf7952ca8 100644 --- a/packages/snaps-controllers/src/services/AbstractExecutionService.test.ts +++ b/packages/snaps-controllers/src/services/ExecutionService.test.ts @@ -3,7 +3,7 @@ import { HandlerType } from '@metamask/snaps-utils'; import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; import { Duration, inMilliseconds } from '@metamask/utils'; -import type { ExecutionServiceArgs } from './AbstractExecutionService'; +import type { ExecutionServiceArgs } from './ExecutionService'; import { NodeThreadExecutionService } from './node'; import { createService } from '../test-utils'; @@ -18,7 +18,7 @@ class MockExecutionService extends NodeThreadExecutionService { } } -describe('AbstractExecutionService', () => { +describe('ExecutionService', () => { afterEach(() => { jest.restoreAllMocks(); }); diff --git a/packages/snaps-controllers/src/services/ExecutionService.ts b/packages/snaps-controllers/src/services/ExecutionService.ts index 3d6898ce21..36c2c0c176 100644 --- a/packages/snaps-controllers/src/services/ExecutionService.ts +++ b/packages/snaps-controllers/src/services/ExecutionService.ts @@ -1,28 +1,83 @@ +import { asV2Middleware } from '@metamask/json-rpc-engine'; +import { JsonRpcEngineV2 as JsonRpcEngine } from '@metamask/json-rpc-engine/v2'; +import { createStreamMiddleware } from '@metamask/json-rpc-middleware-stream'; import type { Messenger } from '@metamask/messenger'; +import ObjectMultiplex from '@metamask/object-multiplex'; +import type { BasePostMessageStream } from '@metamask/post-message-stream'; import type { SnapRpcHookArgs } from '@metamask/snaps-utils'; -import type { Json } from '@metamask/utils'; - -type TerminateSnap = (snapId: string) => Promise; -type TerminateAll = () => Promise; -type ExecuteSnap = (snapData: SnapExecutionData) => Promise; - -type HandleRpcRequest = ( - snapId: string, - options: SnapRpcHookArgs, -) => Promise; - -export type ExecutionService = { - // These fields are required for modular initialisation of the execution - // service in the MetaMask extension. - name: 'ExecutionService'; - state: null; - - terminateSnap: TerminateSnap; - terminateAllSnaps: TerminateAll; - executeSnap: ExecuteSnap; - handleRpcRequest: HandleRpcRequest; +import { SNAP_STREAM_NAMES, logError, logWarning } from '@metamask/snaps-utils'; +import type { + Json, + JsonRpcNotification, + JsonRpcRequest, +} from '@metamask/utils'; +import { + Duration, + assertIsJsonRpcRequest, + hasProperty, + inMilliseconds, +} from '@metamask/utils'; +import { nanoid } from 'nanoid'; +import { pipeline } from 'readable-stream'; +import type { Duplex } from 'readable-stream'; + +import type { ExecutionServiceMethodActions } from './ExecutionService-method-action-types'; +import { log } from '../logging'; +import { Timer } from '../snaps/Timer'; +import { hasTimedOut, withTimeout } from '../utils'; + +const serviceName = 'ExecutionService'; + +export type SetupSnapProvider = (snapId: string, stream: Duplex) => void; + +export type ExecutionServiceArgs = { + setupSnapProvider: SetupSnapProvider; + messenger: ExecutionServiceMessenger; + initTimeout?: number; + pingTimeout?: number; + terminationTimeout?: number; + usePing?: boolean; }; +type JobStreams = { + command: Duplex; + rpc: Duplex; + connection: BasePostMessageStream; + mux: ObjectMultiplex; +}; + +export type Job = { + id: string; + streams: JobStreams; + rpcEngine: JsonRpcEngine; + worker: WorkerType; +}; + +export type TerminateJobArgs = Partial> & + Pick, 'id'>; + +/** + Statuses used for diagnostic purposes + - created: The initial state, no initialization has started + - initializing: Snap execution environment is initializing + - initialized: Snap execution environment has initialized + - executing: Snap source code is being executed + - running: Snap executed and ready for RPC requests + */ +type ExecutionStatus = + | 'created' + | 'initializing' + | 'initialized' + | 'executing' + | 'running'; + +export const MESSENGER_EXPOSED_METHODS = [ + 'terminateSnap', + 'terminateAllSnaps', + 'executeSnap', + 'handleRpcRequest', +] as const; + export type SnapExecutionData = { snapId: string; sourceCode: string; @@ -35,68 +90,452 @@ export type SnapErrorJson = { data?: Json; }; -type ControllerName = 'ExecutionService'; - -export type ErrorMessageEvent = { +export type ExecutionServiceUnhandledErrorEvent = { type: 'ExecutionService:unhandledError'; payload: [string, SnapErrorJson]; }; -export type OutboundRequest = { +export type ExecutionServiceOutboundRequestEvent = { type: 'ExecutionService:outboundRequest'; payload: [string]; }; -export type OutboundResponse = { +export type ExecutionServiceOutboundResponseEvent = { type: 'ExecutionService:outboundResponse'; payload: [string]; }; export type ExecutionServiceEvents = - | ErrorMessageEvent - | OutboundRequest - | OutboundResponse; - -/** - * Handles RPC request. - */ -export type HandleRpcRequestAction = { - type: `${ControllerName}:handleRpcRequest`; - handler: ExecutionService['handleRpcRequest']; -}; - -/** - * Executes a given snap. - */ -export type ExecuteSnapAction = { - type: `${ControllerName}:executeSnap`; - handler: ExecutionService['executeSnap']; -}; + | ExecutionServiceUnhandledErrorEvent + | ExecutionServiceOutboundRequestEvent + | ExecutionServiceOutboundResponseEvent; -/** - * Terminates a given snap. - */ -export type TerminateSnapAction = { - type: `${ControllerName}:terminateSnap`; - handler: ExecutionService['terminateSnap']; -}; - -/** - * Terminates all snaps. - */ -export type TerminateAllSnapsAction = { - type: `${ControllerName}:terminateAllSnaps`; - handler: ExecutionService['terminateAllSnaps']; -}; - -export type ExecutionServiceActions = - | HandleRpcRequestAction - | ExecuteSnapAction - | TerminateSnapAction - | TerminateAllSnapsAction; +export type ExecutionServiceActions = ExecutionServiceMethodActions; export type ExecutionServiceMessenger = Messenger< 'ExecutionService', ExecutionServiceActions, ExecutionServiceEvents >; + +export abstract class ExecutionService { + name: typeof serviceName = serviceName; + + state = null; + + readonly #jobs: Map>; + + readonly #status: Map; + + readonly #setupSnapProvider: SetupSnapProvider; + + readonly #messenger: ExecutionServiceMessenger; + + readonly #initTimeout: number; + + readonly #pingTimeout: number; + + readonly #terminationTimeout: number; + + readonly #usePing: boolean; + + constructor({ + setupSnapProvider, + messenger, + initTimeout = inMilliseconds(60, Duration.Second), + pingTimeout = inMilliseconds(10, Duration.Second), + terminationTimeout = inMilliseconds(1, Duration.Second), + usePing = true, + }: ExecutionServiceArgs) { + this.#jobs = new Map(); + this.#status = new Map(); + this.#setupSnapProvider = setupSnapProvider; + this.#messenger = messenger; + this.#initTimeout = initTimeout; + this.#pingTimeout = pingTimeout; + this.#terminationTimeout = terminationTimeout; + this.#usePing = usePing; + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Performs additional necessary work during job termination. **MUST** be + * implemented by concrete implementations. See + * {@link AbstractExecutionService.terminate} for details. + * + * @param job - The object corresponding to the job to be terminated. + */ + protected abstract terminateJob( + job: TerminateJobArgs, + ): Promise; + + /** + * Terminates the Snap with the specified ID and deletes all its associated + * data. Any subsequent messages targeting the Snap will fail with an error. + * Throws an error if termination fails unexpectedly. + * + * @param snapId - The id of the Snap to be terminated. + */ + public async terminateSnap(snapId: string): Promise { + const job = this.#jobs.get(snapId); + if (!job) { + return; + } + + try { + // Ping worker and tell it to run teardown, continue with termination if it takes too long + const result = await withTimeout( + this.#command(snapId, { + jsonrpc: '2.0', + method: 'terminate', + id: nanoid(), + }), + this.#terminationTimeout, + ); + + if (result === hasTimedOut || result !== 'OK') { + logWarning(`Snap "${snapId}" failed to terminate gracefully.`); + } + } catch { + // Ignore + } + + Object.values(job.streams).forEach((stream) => { + try { + if (!stream.destroyed) { + stream.destroy(); + } + } catch (error) { + logError('Error while destroying stream', error); + } + }); + + await this.terminateJob(job); + + this.#jobs.delete(snapId); + this.#status.delete(snapId); + log(`Snap "${snapId}" terminated.`); + } + + /** + * Initiates a job for a Snap. + * + * @param snapId - The ID of the Snap to initiate a job for. + * @param timer - The timer to use for timeouts. + * @returns Information regarding the created job. + * @throws If the execution service returns an error or execution times out. + */ + async #initJob(snapId: string, timer: Timer): Promise> { + const { streams, worker } = await this.#initStreams(snapId, timer); + + const jsonRpcConnection = createStreamMiddleware(); + + pipeline( + jsonRpcConnection.stream, + streams.command, + jsonRpcConnection.stream, + (error) => { + if (error && !error.message?.match('Premature close')) { + logError(`Command stream failure.`, error); + } + }, + ); + + const rpcEngine = JsonRpcEngine.create({ + middleware: [asV2Middleware(jsonRpcConnection.middleware)], + }); + + const envMetadata = { + id: snapId, + streams, + rpcEngine, + worker, + }; + this.#jobs.set(snapId, envMetadata); + + return envMetadata; + } + + /** + * Sets up the streams for an initiated job. + * + * @param snapId - The Snap ID. + * @param timer - The timer to use for timeouts. + * @returns The streams to communicate with the worker and the worker itself. + * @throws If the execution service returns an error or execution times out. + */ + async #initStreams( + snapId: string, + timer: Timer, + ): Promise<{ streams: JobStreams; worker: WorkerType }> { + const result = await withTimeout(this.initEnvStream(snapId), timer); + + if (result === hasTimedOut) { + // For certain environments, such as the iframe we may have already created the worker and wish to terminate it. + await this.terminateJob({ id: snapId }); + + const status = this.#status.get(snapId); + if (status === 'created') { + // Currently this error can only be thrown by OffscreenExecutionService. + throw new Error( + `The executor for "${snapId}" couldn't start initialization. The offscreen document may not exist.`, + ); + } + throw new Error( + `The executor for "${snapId}" failed to initialize. The iframe/webview/worker failed to load.`, + ); + } + + const { worker, stream: envStream } = result; + const mux = setupMultiplex(envStream, `Snap: "${snapId}"`); + const commandStream = mux.createStream(SNAP_STREAM_NAMES.COMMAND); + + // Handle out-of-band errors, i.e. errors thrown from the Snap outside of the req/res cycle. + // Also keep track of outbound request/responses + const notificationHandler = ( + message: + | JsonRpcRequest + | JsonRpcNotification>, + ) => { + if (hasProperty(message, 'id')) { + return; + } + + if (message.method === 'OutboundRequest') { + this.#messenger.publish('ExecutionService:outboundRequest', snapId); + } else if (message.method === 'OutboundResponse') { + this.#messenger.publish('ExecutionService:outboundResponse', snapId); + } else if (message.method === 'UnhandledError') { + this.#messenger.publish( + 'ExecutionService:unhandledError', + snapId, + (message.params as { error: SnapErrorJson }).error, + ); + commandStream.removeListener('data', notificationHandler); + } else { + logError( + new Error( + `Received unexpected command stream notification "${message.method}".`, + ), + ); + } + }; + + commandStream.on('data', notificationHandler); + + const rpcStream = mux + .createStream(SNAP_STREAM_NAMES.JSON_RPC) + .setMaxListeners(20); + + rpcStream.on('data', (chunk) => { + if (chunk?.data && hasProperty(chunk?.data, 'id')) { + this.#messenger.publish('ExecutionService:outboundRequest', snapId); + } + }); + + // An error handler is not attached to the RPC stream until `setupSnapProvider` is called. + // We must set it up here to prevent errors from bubbling up if the stream is destroyed before then. + rpcStream.on('error', (error) => { + if (error && !error.message?.match('Premature close')) { + logError(`Snap: "${snapId}" - RPC stream failure:`, error); + } + }); + + const originalWrite = rpcStream.write.bind(rpcStream); + + // @ts-expect-error Hack to inspect the messages being written to the stream. + rpcStream.write = (chunk, encoding, callback) => { + // Ignore chain switching notifications as it doesn't matter for the SnapProvider. + if (chunk?.data?.method === 'metamask_chainChanged') { + return true; + } + + if (chunk?.data && hasProperty(chunk?.data, 'id')) { + this.#messenger.publish('ExecutionService:outboundResponse', snapId); + } + + return originalWrite(chunk, encoding, callback); + }; + + return { + streams: { + command: commandStream, + rpc: rpcStream, + connection: envStream, + mux, + }, + worker, + }; + } + + /** + * Abstract function implemented by implementing class that spins up a new worker for a job. + * + * Depending on the execution environment, this may run forever if the Snap fails to start up properly, therefore any call to this function should be wrapped in a timeout. + */ + protected abstract initEnvStream(snapId: string): Promise<{ + worker: WorkerType; + stream: BasePostMessageStream; + }>; + + /** + * Set the execution status of the Snap. + * + * @param snapId - The Snap ID. + * @param status - The current execution status. + */ + protected setSnapStatus(snapId: string, status: ExecutionStatus) { + this.#status.set(snapId, status); + } + + async terminateAllSnaps() { + await Promise.all( + [...this.#jobs.keys()].map(async (snapId) => this.terminateSnap(snapId)), + ); + } + + /** + * Initializes and executes a Snap, setting up the communication channels to the Snap etc. + * + * @param snapData - Data needed for Snap execution. + * @param snapData.snapId - The ID of the Snap to execute. + * @param snapData.sourceCode - The source code of the Snap to execute. + * @param snapData.endowments - The endowments available to the executing Snap. + * @returns A string `OK` if execution succeeded. + * @throws If the execution service returns an error or execution times out. + */ + async executeSnap({ + snapId, + sourceCode, + endowments, + }: SnapExecutionData): Promise { + if (this.#jobs.has(snapId)) { + throw new Error(`"${snapId}" is already running.`); + } + + this.setSnapStatus(snapId, 'created'); + + const timer = new Timer(this.#initTimeout); + + // This may resolve even if the environment has failed to start up fully + const job = await this.#initJob(snapId, timer); + + // Certain environments use ping as part of their initialization and thus can skip it here + if (this.#usePing) { + // Ping the worker to ensure that it started up + const pingResult = await withTimeout( + this.#command(job.id, { + jsonrpc: '2.0', + method: 'ping', + id: nanoid(), + }), + this.#pingTimeout, + ); + + if (pingResult === hasTimedOut) { + throw new Error( + `The executor for "${snapId}" was unreachable. The executor did not respond in time.`, + ); + } + } + + const rpcStream = job.streams.rpc; + + this.#setupSnapProvider(snapId, rpcStream); + + // Use the remaining time as the timer, but ensure that the + // Snap gets at least half the init timeout. + const remainingTime = Math.max(timer.remaining, this.#initTimeout / 2); + + this.setSnapStatus(snapId, 'initialized'); + + const request = { + jsonrpc: '2.0', + method: 'executeSnap', + params: { snapId, sourceCode, endowments }, + id: nanoid(), + }; + + assertIsJsonRpcRequest(request); + + this.setSnapStatus(snapId, 'executing'); + + const result = await withTimeout( + this.#command(job.id, request), + remainingTime, + ); + + if (result === hasTimedOut) { + throw new Error(`${snapId} failed to start.`); + } + + if (result === 'OK') { + this.setSnapStatus(snapId, 'running'); + } + + return result as string; + } + + async #command( + snapId: string, + message: JsonRpcRequest, + ): Promise { + const job = this.#jobs.get(snapId); + if (!job) { + throw new Error(`"${snapId}" is not currently running.`); + } + + log('Parent: Sending Command', message); + return await job.rpcEngine.handle(message); + } + + /** + * Handle RPC request. + * + * @param snapId - The ID of the recipient Snap. + * @param options - Bag of options to pass to the RPC handler. + * @returns Promise that can handle the request. + */ + public async handleRpcRequest( + snapId: string, + options: SnapRpcHookArgs, + ): Promise { + const { handler, request, origin } = options; + + return await this.#command(snapId, { + id: nanoid(), + jsonrpc: '2.0', + method: 'snapRpc', + params: { + snapId, + origin, + handler, + request: request as JsonRpcRequest, + }, + }); + } +} + +/** + * Sets up stream multiplexing for the given stream. + * + * @param connectionStream - The stream to mux. + * @param streamName - The name of the stream, for identification in errors. + * @returns The multiplexed stream. + */ +export function setupMultiplex( + connectionStream: Duplex, + streamName: string, +): ObjectMultiplex { + const mux = new ObjectMultiplex(); + pipeline(connectionStream, mux, connectionStream, (error) => { + if (error && !error.message?.match('Premature close')) { + logError(`"${streamName}" stream failure.`, error); + } + }); + return mux; +} diff --git a/packages/snaps-controllers/src/services/browser.test.ts b/packages/snaps-controllers/src/services/browser.test.ts index b4cf203e56..dfe475be40 100644 --- a/packages/snaps-controllers/src/services/browser.test.ts +++ b/packages/snaps-controllers/src/services/browser.test.ts @@ -2,7 +2,7 @@ import * as BrowserExport from './browser'; describe('browser entrypoint', () => { const expectedExports = [ - 'AbstractExecutionService', + 'ExecutionService', 'setupMultiplex', 'IframeExecutionService', 'OffscreenExecutionService', diff --git a/packages/snaps-controllers/src/services/browser.ts b/packages/snaps-controllers/src/services/browser.ts index 871061032c..f5d3151ba5 100644 --- a/packages/snaps-controllers/src/services/browser.ts +++ b/packages/snaps-controllers/src/services/browser.ts @@ -1,6 +1,22 @@ // Subset of exports meant for browser environments, omits Node.js services -export * from './AbstractExecutionService'; -export type * from './ExecutionService'; +export type { + ExecutionServiceActions, + ExecutionServiceEvents, + ExecutionServiceMessenger, + ExecutionServiceOutboundRequestEvent, + ExecutionServiceOutboundResponseEvent, + ExecutionServiceUnhandledErrorEvent, + SetupSnapProvider, + SnapErrorJson, + SnapExecutionData, +} from './ExecutionService'; +export { ExecutionService, setupMultiplex } from './ExecutionService'; +export type { + ExecutionServiceTerminateSnapAction, + ExecutionServiceTerminateAllSnapsAction, + ExecutionServiceExecuteSnapAction, + ExecutionServiceHandleRpcRequestAction, +} from './ExecutionService-method-action-types'; export * from './ProxyPostMessageStream'; export * from './iframe'; export * from './offscreen'; diff --git a/packages/snaps-controllers/src/services/iframe/IframeExecutionService.ts b/packages/snaps-controllers/src/services/iframe/IframeExecutionService.ts index cb49d91f89..70daadda29 100644 --- a/packages/snaps-controllers/src/services/iframe/IframeExecutionService.ts +++ b/packages/snaps-controllers/src/services/iframe/IframeExecutionService.ts @@ -6,14 +6,14 @@ import { withTimeout } from '../../utils'; import type { ExecutionServiceArgs, TerminateJobArgs, -} from '../AbstractExecutionService'; -import { AbstractExecutionService } from '../AbstractExecutionService'; +} from '../ExecutionService'; +import { ExecutionService } from '../ExecutionService'; type IframeExecutionEnvironmentServiceArgs = { iframeUrl: URL; } & ExecutionServiceArgs; -export class IframeExecutionService extends AbstractExecutionService { +export class IframeExecutionService extends ExecutionService { public iframeUrl: URL; constructor({ diff --git a/packages/snaps-controllers/src/services/index.ts b/packages/snaps-controllers/src/services/index.ts index 2e0ea1263b..5447d7e102 100644 --- a/packages/snaps-controllers/src/services/index.ts +++ b/packages/snaps-controllers/src/services/index.ts @@ -1,5 +1,20 @@ -export * from './AbstractExecutionService'; -export type * from './ExecutionService'; +export type { + ExecutionServiceActions, + ExecutionServiceEvents, + ExecutionServiceMessenger, + ExecutionServiceOutboundRequestEvent, + ExecutionServiceOutboundResponseEvent, + ExecutionServiceUnhandledErrorEvent, + SnapErrorJson, + SnapExecutionData, +} from './ExecutionService'; +export { ExecutionService, setupMultiplex } from './ExecutionService'; +export type { + ExecutionServiceTerminateSnapAction, + ExecutionServiceTerminateAllSnapsAction, + ExecutionServiceExecuteSnapAction, + ExecutionServiceHandleRpcRequestAction, +} from './ExecutionService-method-action-types'; export * from './ProxyPostMessageStream'; export * from './iframe'; export * from './offscreen'; diff --git a/packages/snaps-controllers/src/services/node-js/NodeProcessExecutionService.ts b/packages/snaps-controllers/src/services/node-js/NodeProcessExecutionService.ts index 26f38f0d82..24bd916642 100644 --- a/packages/snaps-controllers/src/services/node-js/NodeProcessExecutionService.ts +++ b/packages/snaps-controllers/src/services/node-js/NodeProcessExecutionService.ts @@ -3,10 +3,10 @@ import { ProcessParentMessageStream } from '@metamask/post-message-stream/node'; import type { ChildProcess } from 'child_process'; import { fork } from 'child_process'; -import type { TerminateJobArgs } from '..'; -import { AbstractExecutionService } from '..'; +import type { TerminateJobArgs } from '../ExecutionService'; +import { ExecutionService } from '../ExecutionService'; -export class NodeProcessExecutionService extends AbstractExecutionService { +export class NodeProcessExecutionService extends ExecutionService { protected async initEnvStream(snapId: string): Promise<{ worker: ChildProcess; stream: BasePostMessageStream; diff --git a/packages/snaps-controllers/src/services/node-js/NodeThreadExecutionService.ts b/packages/snaps-controllers/src/services/node-js/NodeThreadExecutionService.ts index 9165986784..dfcdc7d083 100644 --- a/packages/snaps-controllers/src/services/node-js/NodeThreadExecutionService.ts +++ b/packages/snaps-controllers/src/services/node-js/NodeThreadExecutionService.ts @@ -2,10 +2,10 @@ import type { BasePostMessageStream } from '@metamask/post-message-stream'; import { ThreadParentMessageStream } from '@metamask/post-message-stream/node'; import { Worker } from 'worker_threads'; -import type { TerminateJobArgs } from '..'; -import { AbstractExecutionService } from '..'; +import { ExecutionService } from '../ExecutionService'; +import type { TerminateJobArgs } from '../ExecutionService'; -export class NodeThreadExecutionService extends AbstractExecutionService { +export class NodeThreadExecutionService extends ExecutionService { protected async initEnvStream(snapId: string): Promise<{ worker: Worker; stream: BasePostMessageStream; diff --git a/packages/snaps-controllers/src/services/offscreen/OffscreenExecutionService.ts b/packages/snaps-controllers/src/services/offscreen/OffscreenExecutionService.ts index 85b2ff231f..231445e8b0 100644 --- a/packages/snaps-controllers/src/services/offscreen/OffscreenExecutionService.ts +++ b/packages/snaps-controllers/src/services/offscreen/OffscreenExecutionService.ts @@ -1,6 +1,6 @@ import { BrowserRuntimePostMessageStream } from '@metamask/post-message-stream'; -import type { ExecutionServiceArgs } from '../AbstractExecutionService'; +import type { ExecutionServiceArgs } from '../ExecutionService'; import { ProxyExecutionService } from '../proxy/ProxyExecutionService'; type OffscreenExecutionEnvironmentServiceArgs = { diff --git a/packages/snaps-controllers/src/services/proxy/ProxyExecutionService.ts b/packages/snaps-controllers/src/services/proxy/ProxyExecutionService.ts index 03d4fe531d..a452c57299 100644 --- a/packages/snaps-controllers/src/services/proxy/ProxyExecutionService.ts +++ b/packages/snaps-controllers/src/services/proxy/ProxyExecutionService.ts @@ -4,15 +4,15 @@ import { nanoid } from 'nanoid'; import type { ExecutionServiceArgs, TerminateJobArgs, -} from '../AbstractExecutionService'; -import { AbstractExecutionService } from '../AbstractExecutionService'; +} from '../ExecutionService'; +import { ExecutionService } from '../ExecutionService'; import { ProxyPostMessageStream } from '../ProxyPostMessageStream'; type ProxyExecutionEnvironmentServiceArgs = { stream: BasePostMessageStream; } & ExecutionServiceArgs; -export class ProxyExecutionService extends AbstractExecutionService { +export class ProxyExecutionService extends ExecutionService { readonly #stream: BasePostMessageStream; /** diff --git a/packages/snaps-controllers/src/services/webview/WebViewExecutionService.ts b/packages/snaps-controllers/src/services/webview/WebViewExecutionService.ts index 9a2a97982e..c03abb7f24 100644 --- a/packages/snaps-controllers/src/services/webview/WebViewExecutionService.ts +++ b/packages/snaps-controllers/src/services/webview/WebViewExecutionService.ts @@ -1,17 +1,17 @@ import type { WebViewInterface } from './WebViewMessageStream'; import { WebViewMessageStream } from './WebViewMessageStream'; -import { AbstractExecutionService } from '../AbstractExecutionService'; +import { ExecutionService } from '../ExecutionService'; import type { ExecutionServiceArgs, TerminateJobArgs, -} from '../AbstractExecutionService'; +} from '../ExecutionService'; export type WebViewExecutionServiceArgs = ExecutionServiceArgs & { createWebView: (jobId: string) => Promise; removeWebView: (jobId: string) => void; }; -export class WebViewExecutionService extends AbstractExecutionService { +export class WebViewExecutionService extends ExecutionService { readonly #createWebView; readonly #removeWebView; diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index 94ec443b68..e81c630ae1 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -95,11 +95,11 @@ import { SNAP_APPROVAL_RESULT, SNAP_APPROVAL_UPDATE, } from './SnapController'; -import { AbstractExecutionService, setupMultiplex } from '../services'; +import { ExecutionService, setupMultiplex } from '../services'; +import type { TerminateJobArgs } from '../services/ExecutionService'; import type { ExecutionServiceMessenger, NodeThreadExecutionService, - TerminateJobArgs, } from '../services/node'; import type { SnapControllerStateWithStorageService } from '../test-utils'; import { @@ -1870,7 +1870,7 @@ describe('SnapController', () => { state: { snaps: getPersistedSnapsState() }, }); - class BrickedExecutionService extends AbstractExecutionService { + class BrickedExecutionService extends ExecutionService { constructor(messenger: ExecutionServiceMessenger) { super({ messenger, setupSnapProvider: jest.fn(), pingTimeout: 1 }); } @@ -1982,18 +1982,16 @@ describe('SnapController', () => { }); const rootMessenger = getRootMessenger(); - const [snapController, service] = await getSnapControllerWithEES( - getSnapControllerOptions({ - maxRequestTime: 50, - rootMessenger, - detectSnapLocation: loopbackDetect({ - manifest, - files: [sourceCode, svgIcon as VirtualFile], - }), + const options = getSnapControllerOptions({ + maxRequestTime: 50, + rootMessenger, + detectSnapLocation: loopbackDetect({ + manifest, + files: [sourceCode, svgIcon as VirtualFile], }), - ); + }); - const spy = jest.spyOn(service, 'executeSnap'); + const [snapController, service] = await getSnapControllerWithEES(options); await snapController.installSnaps(MOCK_ORIGIN, { [MOCK_SNAP_ID]: {}, @@ -2043,7 +2041,21 @@ describe('SnapController', () => { expect(await promise).toBe('foo'); - expect(spy).toHaveBeenCalledTimes(2); + expect(options.messenger.call).toHaveBeenNthCalledWith( + 8, + 'ExecutionService:executeSnap', + expect.objectContaining({ + snapId: MOCK_SNAP_ID, + }), + ); + + expect(options.messenger.call).toHaveBeenNthCalledWith( + 19, + 'ExecutionService:executeSnap', + expect.objectContaining({ + snapId: MOCK_SNAP_ID, + }), + ); // @ts-expect-error Accessing protected value. service.terminateJob = originalTerminateFunction; @@ -2127,7 +2139,10 @@ describe('SnapController', () => { rootMessenger.registerActionHandler( 'ExecutionService:executeSnap', - async () => await sleep(300), + async () => { + await sleep(300); + return 'OK'; + }, ); const snap = snapController.getSnapExpect(MOCK_SNAP_ID); diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 9a3b7e4aaf..2e47e79aad 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -181,11 +181,11 @@ import type { } from '../interface'; import { log } from '../logging'; import type { - ExecuteSnapAction, ExecutionServiceEvents, - HandleRpcRequestAction, + ExecutionServiceExecuteSnapAction, + ExecutionServiceHandleRpcRequestAction, + ExecutionServiceTerminateSnapAction, SnapErrorJson, - TerminateSnapAction, } from '../services'; import type { EncryptionResult, @@ -530,9 +530,9 @@ export type AllowedActions = | RevokePermissionForAllSubjects | GrantPermissions | ApprovalControllerAddRequestAction - | HandleRpcRequestAction - | ExecuteSnapAction - | TerminateSnapAction + | ExecutionServiceHandleRpcRequestAction + | ExecutionServiceExecuteSnapAction + | ExecutionServiceTerminateSnapAction | UpdateCaveat | ApprovalControllerUpdateRequestStateAction | GetResult diff --git a/packages/snaps-controllers/src/test-utils/controller.tsx b/packages/snaps-controllers/src/test-utils/controller.tsx index b166da106c..7211352855 100644 --- a/packages/snaps-controllers/src/test-utils/controller.tsx +++ b/packages/snaps-controllers/src/test-utils/controller.tsx @@ -61,10 +61,7 @@ import type { StoredInterface, } from '../interface/SnapInterfaceController'; import type { MultichainRoutingServiceMessenger } from '../multichain/MultichainRoutingService'; -import type { - AbstractExecutionService, - ExecutionServiceMessenger, -} from '../services'; +import type { ExecutionService, ExecutionServiceMessenger } from '../services'; import type { SnapsRegistryActions, SnapsRegistryEvents, @@ -426,7 +423,10 @@ export const getRootMessenger = () => { () => undefined, ); - messenger.registerActionHandler('ExecutionService:executeSnap', asyncNoOp); + messenger.registerActionHandler( + 'ExecutionService:executeSnap', + async () => 'OK', + ); messenger.registerActionHandler( 'ExecutionService:handleRpcRequest', asyncNoOp, @@ -693,7 +693,7 @@ export const getSnapController = async ( export const getSnapControllerWithEES = async ( options = getSnapControllerOptions(), - service?: AbstractExecutionService, + service?: ExecutionService, init = true, ) => { const _service = diff --git a/packages/snaps-controllers/src/test-utils/execution-environment.ts b/packages/snaps-controllers/src/test-utils/execution-environment.ts index 575ebce491..0bd738d2c6 100644 --- a/packages/snaps-controllers/src/test-utils/execution-environment.ts +++ b/packages/snaps-controllers/src/test-utils/execution-environment.ts @@ -7,12 +7,11 @@ import { pipeline } from 'readable-stream'; import { MOCK_BLOCK_NUMBER } from './constants'; import type { RootMessenger } from './controller'; import type { - ExecutionService, ExecutionServiceActions, ExecutionServiceEvents, - SetupSnapProvider, SnapExecutionData, } from '../services'; +import type { SetupSnapProvider } from '../services/ExecutionService'; import { NodeThreadExecutionService, setupMultiplex } from '../services/node'; export const getNodeEESMessenger = (messenger: RootMessenger) => { @@ -59,7 +58,7 @@ export const getNodeEES = ( }), }); -export class ExecutionEnvironmentStub implements ExecutionService { +export class ExecutionEnvironmentStub { name: 'ExecutionService' = 'ExecutionService' as const; state = null; diff --git a/packages/snaps-controllers/src/test-utils/service.ts b/packages/snaps-controllers/src/test-utils/service.ts index f324a2257e..2c3f0f129e 100644 --- a/packages/snaps-controllers/src/test-utils/service.ts +++ b/packages/snaps-controllers/src/test-utils/service.ts @@ -6,7 +6,7 @@ import { pipeline } from 'readable-stream'; import type { Duplex } from 'readable-stream'; import { MOCK_BLOCK_NUMBER } from './constants'; -import type { ErrorMessageEvent } from '../services'; +import type { ExecutionServiceUnhandledErrorEvent } from '../services'; import { setupMultiplex } from '../services'; export const createService = < @@ -18,9 +18,11 @@ export const createService = < 'messenger' | 'setupSnapProvider' >, ) => { - const messenger = new Messenger<'ExecutionService', never, ErrorMessageEvent>( - { namespace: 'ExecutionService' }, - ); + const messenger = new Messenger< + 'ExecutionService', + never, + ExecutionServiceUnhandledErrorEvent + >({ namespace: 'ExecutionService' }); const service = new ServiceClass({ messenger, diff --git a/packages/snaps-jest/src/environment.ts b/packages/snaps-jest/src/environment.ts index 130a433c16..000b4fa984 100644 --- a/packages/snaps-jest/src/environment.ts +++ b/packages/snaps-jest/src/environment.ts @@ -2,7 +2,7 @@ import type { EnvironmentContext, JestEnvironmentConfig, } from '@jest/environment'; -import type { AbstractExecutionService } from '@metamask/snaps-controllers'; +import type { ExecutionService } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; import { installSnap } from '@metamask/snaps-simulation'; import type { @@ -85,7 +85,7 @@ export class SnapsEnvironment extends NodeEnvironment { async installSnap< Service extends new ( ...args: any[] - ) => InstanceType, + ) => InstanceType, >( snapId: string = this.snapId, options: Partial> = {}, diff --git a/packages/snaps-jest/src/helpers.ts b/packages/snaps-jest/src/helpers.ts index 1508cfa2c0..4602a1a2b3 100644 --- a/packages/snaps-jest/src/helpers.ts +++ b/packages/snaps-jest/src/helpers.ts @@ -1,4 +1,4 @@ -import type { AbstractExecutionService } from '@metamask/snaps-controllers'; +import type { ExecutionService } from '@metamask/snaps-controllers'; import type { AccountSelectorState, AssetSelectorState, @@ -35,9 +35,7 @@ const log = createModuleLogger(rootLogger, 'helpers'); * @returns The options. */ function getOptions< - Service extends new ( - ...args: any[] - ) => InstanceType, + Service extends new (...args: any[]) => InstanceType, >( snapId: SnapId | Partial> | undefined, options: Partial>, @@ -102,9 +100,7 @@ export async function installSnap(): Promise; * @throws If the built-in server is not running, and no snap ID is provided. */ export async function installSnap< - Service extends new ( - ...args: any[] - ) => InstanceType, + Service extends new (...args: any[]) => InstanceType, >(options: Partial>): Promise; /** @@ -140,9 +136,7 @@ export async function installSnap< * @throws If the built-in server is not running, and no snap ID is provided. */ export async function installSnap< - Service extends new ( - ...args: any[] - ) => InstanceType, + Service extends new (...args: any[]) => InstanceType, >( snapId: SnapId, options?: Partial>, @@ -181,9 +175,7 @@ export async function installSnap< * @throws If the built-in server is not running, and no snap ID is provided. */ export async function installSnap< - Service extends new ( - ...args: any[] - ) => InstanceType, + Service extends new (...args: any[]) => InstanceType, >( snapId?: SnapId | Partial>, options: Partial> = {}, diff --git a/packages/snaps-simulation/src/request.ts b/packages/snaps-simulation/src/request.ts index 4ff5608f42..8267ae1c70 100644 --- a/packages/snaps-simulation/src/request.ts +++ b/packages/snaps-simulation/src/request.ts @@ -1,4 +1,4 @@ -import type { AbstractExecutionService } from '@metamask/snaps-controllers'; +import type { ExecutionService } from '@metamask/snaps-controllers'; import { type ComponentOrElement, ComponentOrElementStruct, @@ -38,7 +38,7 @@ import type { export type HandleRequestOptions = { snapId: SnapId; store: Store; - executionService: AbstractExecutionService; + executionService: ExecutionService; handler: HandlerType; controllerMessenger: RootControllerMessenger; simulationOptions: SimulationOptions; diff --git a/packages/snaps-simulation/src/simulation.ts b/packages/snaps-simulation/src/simulation.ts index 9c2e097119..23d955d171 100644 --- a/packages/snaps-simulation/src/simulation.ts +++ b/packages/snaps-simulation/src/simulation.ts @@ -12,7 +12,7 @@ import { type Caveat, type RequestedPermissions, } from '@metamask/permission-controller'; -import type { AbstractExecutionService } from '@metamask/snaps-controllers'; +import type { ExecutionService } from '@metamask/snaps-controllers'; import { detectSnapLocation, fetchSnap, @@ -88,7 +88,7 @@ export type ExecutionServiceOptions< Service extends new (...args: any[]) => any, > = Omit< ConstructorParameters[0], - keyof ConstructorParameters>[0] + keyof ConstructorParameters[0] >; /** @@ -102,9 +102,7 @@ export type ExecutionServiceOptions< * @template Service - The type of the execution service. */ export type InstallSnapOptions< - Service extends new ( - ...args: any[] - ) => InstanceType>, + Service extends new (...args: any[]) => InstanceType, > = ExecutionServiceOptions extends Record ? { @@ -121,7 +119,7 @@ export type InstallSnapOptions< export type InstalledSnap = { snapId: SnapId; store: Store; - executionService: InstanceType; + executionService: InstanceType; controllerMessenger: Messenger< NamespacedName, ActionConstraint, @@ -406,9 +404,7 @@ export type MultichainMiddlewareHooks = { * @template Service - The type of the execution service. */ export async function installSnap< - Service extends new ( - ...args: any[] - ) => InstanceType, + Service extends new (...args: any[]) => InstanceType, >( snapId: SnapId, { @@ -481,8 +477,8 @@ export async function installSnap< }); // Create execution service. - const ExecutionService = executionService ?? NodeThreadExecutionService; - const service = new ExecutionService({ + const ActualService = executionService ?? NodeThreadExecutionService; + const service = new ActualService({ ...executionServiceOptions, messenger: new Messenger({ namespace: 'ExecutionService',