diff --git a/packages/snaps-controllers/src/services/AbstractExecutionService.test.ts b/packages/snaps-controllers/src/services/AbstractExecutionService.test.ts index 5406137328..516594d1a3 100644 --- a/packages/snaps-controllers/src/services/AbstractExecutionService.test.ts +++ b/packages/snaps-controllers/src/services/AbstractExecutionService.test.ts @@ -58,10 +58,12 @@ describe('AbstractExecutionService', () => { `, endowments: ['console'], }), - ).rejects.toThrow('The Snaps execution environment failed to start.'); + ).rejects.toThrow( + 'The executor for "npm:@metamask/example-snap" was unreachable. The executor did not respond in time.', + ); }); - it('throws an error if execution environment fails to init', async () => { + it('throws an error if execution environment fails to start initialization', async () => { const { service } = createService(MockExecutionService); // @ts-expect-error Accessing private property and returning unusable worker. @@ -78,7 +80,33 @@ describe('AbstractExecutionService', () => { `, endowments: ['console'], }), - ).rejects.toThrow('The Snaps execution environment failed to start.'); + ).rejects.toThrow( + `The executor for "npm:@metamask/example-snap" couldn't start initialization. The offscreen document may not exist.`, + ); + }); + + it('throws an error if execution environment fails to init', async () => { + const { service } = createService(MockExecutionService); + + // @ts-expect-error Accessing private property and returning unusable worker. + service.initEnvStream = async (snapId: string) => + new Promise((_resolve) => { + // @ts-expect-error Accessing private property to mirror updating state + service.setSnapStatus(snapId, 'initializing'); + // no-op + }); + + await expect( + service.executeSnap({ + snapId: MOCK_SNAP_ID, + sourceCode: ` + console.log('foo'); + `, + endowments: ['console'], + }), + ).rejects.toThrow( + 'The executor for "npm:@metamask/example-snap" failed to initialize. The iframe/webview/worker failed to load.', + ); }); it('throws an error if Snap fails to init', async () => { diff --git a/packages/snaps-controllers/src/services/AbstractExecutionService.ts b/packages/snaps-controllers/src/services/AbstractExecutionService.ts index 6985ae58d3..cbb099f37b 100644 --- a/packages/snaps-controllers/src/services/AbstractExecutionService.ts +++ b/packages/snaps-controllers/src/services/AbstractExecutionService.ts @@ -60,6 +60,21 @@ export type Job = { 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 { @@ -69,6 +84,8 @@ export abstract class AbstractExecutionService readonly #jobs: Map>; + readonly #status: Map; + readonly #setupSnapProvider: SetupSnapProvider; readonly #messenger: ExecutionServiceMessenger; @@ -90,6 +107,7 @@ export abstract class AbstractExecutionService usePing = true, }: ExecutionServiceArgs) { this.#jobs = new Map(); + this.#status = new Map(); this.#setupSnapProvider = setupSnapProvider; this.#messenger = messenger; this.#initTimeout = initTimeout; @@ -186,6 +204,7 @@ export abstract class AbstractExecutionService this.terminateJob(job); this.#jobs.delete(snapId); + this.#status.delete(snapId); log(`Snap "${snapId}" terminated.`); } @@ -244,7 +263,17 @@ export abstract class AbstractExecutionService if (result === hasTimedOut) { // For certain environments, such as the iframe we may have already created the worker and wish to terminate it. this.terminateJob({ id: snapId }); - throw new Error('The Snaps execution environment failed to start.'); + + 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; @@ -307,6 +336,16 @@ export abstract class AbstractExecutionService 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)), @@ -332,6 +371,8 @@ export abstract class AbstractExecutionService 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 @@ -350,7 +391,9 @@ export abstract class AbstractExecutionService ); if (pingResult === hasTimedOut) { - throw new Error('The Snaps execution environment failed to start.'); + throw new Error( + `The executor for "${snapId}" was unreachable. The executor did not respond in time.`, + ); } } @@ -360,6 +403,8 @@ export abstract class AbstractExecutionService const remainingTime = timer.remaining; + this.setSnapStatus(snapId, 'initialized'); + const request = { jsonrpc: '2.0', method: 'executeSnap', @@ -369,6 +414,8 @@ export abstract class AbstractExecutionService assertIsJsonRpcRequest(request); + this.setSnapStatus(snapId, 'executing'); + const result = await withTimeout( this.#command(job.id, request), remainingTime, @@ -378,6 +425,10 @@ export abstract class AbstractExecutionService throw new Error(`${snapId} failed to start.`); } + if (result === 'OK') { + this.setSnapStatus(snapId, 'running'); + } + return result as string; } diff --git a/packages/snaps-controllers/src/services/iframe/IframeExecutionService.ts b/packages/snaps-controllers/src/services/iframe/IframeExecutionService.ts index a64ae65f4a..71812eb559 100644 --- a/packages/snaps-controllers/src/services/iframe/IframeExecutionService.ts +++ b/packages/snaps-controllers/src/services/iframe/IframeExecutionService.ts @@ -37,6 +37,8 @@ export class IframeExecutionService extends AbstractExecutionService { worker: Window; stream: BasePostMessageStream; }> { + this.setSnapStatus(snapId, 'initializing'); + const iframeWindow = await createWindow({ uri: this.iframeUrl.toString(), id: snapId, diff --git a/packages/snaps-controllers/src/services/node-js/NodeProcessExecutionService.ts b/packages/snaps-controllers/src/services/node-js/NodeProcessExecutionService.ts index 37efc15580..75c344958a 100644 --- a/packages/snaps-controllers/src/services/node-js/NodeProcessExecutionService.ts +++ b/packages/snaps-controllers/src/services/node-js/NodeProcessExecutionService.ts @@ -7,10 +7,12 @@ import type { TerminateJobArgs } from '..'; import { AbstractExecutionService } from '..'; export class NodeProcessExecutionService extends AbstractExecutionService { - protected async initEnvStream(): Promise<{ + protected async initEnvStream(snapId: string): Promise<{ worker: ChildProcess; stream: BasePostMessageStream; }> { + this.setSnapStatus(snapId, 'initializing'); + const worker = fork( require.resolve('@metamask/snaps-execution-environments/node-process'), { diff --git a/packages/snaps-controllers/src/services/node-js/NodeThreadExecutionService.ts b/packages/snaps-controllers/src/services/node-js/NodeThreadExecutionService.ts index a864b39f4f..223506acf5 100644 --- a/packages/snaps-controllers/src/services/node-js/NodeThreadExecutionService.ts +++ b/packages/snaps-controllers/src/services/node-js/NodeThreadExecutionService.ts @@ -6,10 +6,12 @@ import type { TerminateJobArgs } from '..'; import { AbstractExecutionService } from '..'; export class NodeThreadExecutionService extends AbstractExecutionService { - protected async initEnvStream(): Promise<{ + protected async initEnvStream(snapId: string): Promise<{ worker: Worker; stream: BasePostMessageStream; }> { + this.setSnapStatus(snapId, 'initializing'); + const worker = new Worker( require.resolve('@metamask/snaps-execution-environments/node-thread'), { diff --git a/packages/snaps-controllers/src/services/proxy/ProxyExecutionService.ts b/packages/snaps-controllers/src/services/proxy/ProxyExecutionService.ts index d51acd47e8..251f7131b9 100644 --- a/packages/snaps-controllers/src/services/proxy/ProxyExecutionService.ts +++ b/packages/snaps-controllers/src/services/proxy/ProxyExecutionService.ts @@ -71,6 +71,8 @@ export class ProxyExecutionService extends AbstractExecutionService { * @returns An object with the worker ID and stream. */ protected async initEnvStream(snapId: string) { + this.setSnapStatus(snapId, 'initializing'); + const stream = new ProxyPostMessageStream({ stream: this.#stream, jobId: snapId, diff --git a/packages/snaps-controllers/src/services/webview/WebViewExecutionService.ts b/packages/snaps-controllers/src/services/webview/WebViewExecutionService.ts index 3fb72d90d1..623a939502 100644 --- a/packages/snaps-controllers/src/services/webview/WebViewExecutionService.ts +++ b/packages/snaps-controllers/src/services/webview/WebViewExecutionService.ts @@ -40,6 +40,8 @@ export class WebViewExecutionService extends AbstractExecutionService