Skip to content

Commit 1ce011c

Browse files
fix: show user message on unhandled exception #3868
Problem: When webview backend/server code throws an unhandled exception, it is only logged. The UI doesn't expose the message or any indication about next steps. Solution: When a webview throws an unhandled exception, show an error message with a "View Logs" button. Problem: The "View Logs" feature can get confused on subsequent errors, the error will not be highlighted even though it's in the logs. Apparently vscode.TextDocument.getText() caches the text and only updates once something triggers it. So when we programatically request the text, we sometimes get stale results. Solution: Use the vscode "revert" command to force getText() to read from disk. Signed-off-by: nkomonen <[email protected]>
1 parent 155e7f2 commit 1ce011c

File tree

8 files changed

+182
-41
lines changed

8 files changed

+182
-41
lines changed

src/auth/ui/vue/show.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { connectToEnterpriseSso } from '../../../codewhisperer/util/getStartUrl'
4343
import { trustedDomainCancellation } from '../../sso/model'
4444
import { FeatureId, CredentialSourceId, Result, telemetry } from '../../../shared/telemetry/telemetry'
4545
import { AuthFormId, isBuilderIdAuth } from './authForms/types'
46+
import { handleWebviewError } from '../../../webviews/server'
4647

4748
export class AuthWebview extends VueWebview {
4849
public override id: string = 'authWebview'
@@ -129,11 +130,11 @@ export class AuthWebview extends VueWebview {
129130
}
130131

131132
async startCodeWhispererBuilderIdSetup(): Promise<AuthError | undefined> {
132-
return this.ssoSetup(() => awsIdSignIn())
133+
return this.ssoSetup('startCodeWhispererBuilderIdSetup', () => awsIdSignIn())
133134
}
134135

135136
async startCodeCatalystBuilderIdSetup(): Promise<AuthError | undefined> {
136-
return this.ssoSetup(() => setupCodeCatalystBuilderId(this.codeCatalystAuth))
137+
return this.ssoSetup('startCodeCatalystBuilderIdSetup', () => setupCodeCatalystBuilderId(this.codeCatalystAuth))
137138
}
138139

139140
isCodeWhispererBuilderIdConnected(): boolean {
@@ -193,7 +194,7 @@ export class AuthWebview extends VueWebview {
193194
const ssoProfile = createSsoProfile(startUrl, regionId)
194195
await Auth.instance.createConnection(ssoProfile)
195196
}
196-
return this.ssoSetup(setupFunc)
197+
return this.ssoSetup('createIdentityCenterConnection', setupFunc)
197198
}
198199

199200
/**
@@ -203,10 +204,17 @@ export class AuthWebview extends VueWebview {
203204
const setupFunc = () => {
204205
return connectToEnterpriseSso(startUrl, regionId)
205206
}
206-
return this.ssoSetup(setupFunc)
207+
return this.ssoSetup('startCWIdentityCenterSetup', setupFunc)
207208
}
208209

209-
private async ssoSetup(setupFunc: () => Promise<any>): Promise<AuthError | undefined> {
210+
/**
211+
* This wraps the execution of the given setupFunc() and handles common errors from the SSO setup process.
212+
*
213+
* @param methodName A value that will help identify which high level function called this method.
214+
* @param setupFunc The function which will be executed in a try/catch so that we can handle common errors.
215+
* @returns
216+
*/
217+
private async ssoSetup(methodName: string, setupFunc: () => Promise<any>): Promise<AuthError | undefined> {
210218
try {
211219
await setupFunc()
212220
return
@@ -236,7 +244,11 @@ export class AuthWebview extends VueWebview {
236244
return { id: 'badStartUrl', text: `Connection failed. Please verify your start URL.` }
237245
}
238246

239-
getLogger().error('AuthWebview: Failed to setup: %s', (e as Error).message)
247+
// If SSO setup fails we want to be able to show the user an error in the UI, due to this we cannot
248+
// throw an error here. So instead this will additionally show an error message that provides more
249+
// detailed information.
250+
handleWebviewError(e, this.id, methodName)
251+
240252
return { id: 'defaultFailure', text: 'Failed to setup.' }
241253
}
242254
}

src/extension.ts

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,15 @@ import globals, { initialize } from './shared/extensionGlobals'
6262
import { join } from 'path'
6363
import { Experiments, Settings } from './shared/settings'
6464
import { isReleaseVersion } from './shared/vscode/env'
65-
import { Commands, registerErrorHandler } from './shared/vscode/commands2'
66-
import { ToolkitError, isUserCancelledError, resolveErrorMessageToDisplay } from './shared/errors'
67-
import { Logging } from './shared/logger/commands'
65+
import { Commands, registerErrorHandler as registerCommandErrorHandler } from './shared/vscode/commands2'
6866
import { UriHandler } from './shared/vscode/uriHandler'
6967
import { telemetry } from './shared/telemetry/telemetry'
7068
import { Auth } from './auth/auth'
71-
import { showMessageWithUrl } from './shared/utilities/messages'
7269
import { openUrl } from './shared/utilities/vsCodeUtils'
70+
import { isUserCancelledError, resolveErrorMessageToDisplay, ToolkitError } from './shared/errors'
71+
import { Logging } from './shared/logger/commands'
72+
import { showMessageWithUrl } from './shared/utilities/messages'
73+
import { registerWebviewErrorHandler } from './webviews/server'
7374

7475
let localize: nls.LocalizeFunc
7576

@@ -89,9 +90,13 @@ export async function activate(context: vscode.ExtensionContext) {
8990
)
9091
globals.outputChannel = toolkitOutputChannel
9192

92-
registerErrorHandler((info, error) => {
93+
registerCommandErrorHandler((info, error) => {
9394
const defaultMessage = localize('AWS.generic.message.error', 'Failed to run command: {0}', info.id)
94-
handleError(error, info.id, defaultMessage)
95+
logAndShowError(error, info.id, defaultMessage)
96+
})
97+
98+
registerWebviewErrorHandler((error: unknown, webviewId: string, command: string) => {
99+
logAndShowWebviewError(error, webviewId, command)
95100
})
96101

97102
if (isCloud9()) {
@@ -273,29 +278,6 @@ export async function activate(context: vscode.ExtensionContext) {
273278
}
274279
}
275280

276-
// This is only being used for errors from commands although there's plenty of other places where it
277-
// could be used. It needs to be apart of some sort of `core` module that is guaranteed to initialize
278-
// prior to every other Toolkit component. Logging and telemetry would fit well within this core module.
279-
async function handleError(error: unknown, topic: string, defaultMessage: string) {
280-
if (isUserCancelledError(error)) {
281-
getLogger().verbose(`${topic}: user cancelled`)
282-
return
283-
}
284-
const logsItem = localize('AWS.generic.message.viewLogs', 'View Logs...')
285-
const logId = getLogger().error(`${topic}: %s`, error)
286-
const message = resolveErrorMessageToDisplay(error, defaultMessage)
287-
288-
if (error instanceof ToolkitError && error.documentationUri) {
289-
await showMessageWithUrl(message, error.documentationUri, 'View Documentation', 'error')
290-
} else {
291-
await vscode.window.showErrorMessage(message, logsItem).then(async resp => {
292-
if (resp === logsItem) {
293-
await Logging.declared.viewLogsAtMessage.execute(logId)
294-
}
295-
})
296-
}
297-
}
298-
299281
export async function deactivate() {
300282
await codewhispererShutdown()
301283
await globals.telemetry.shutdown()
@@ -365,6 +347,56 @@ function wrapWithProgressForCloud9(channel: vscode.OutputChannel): (typeof vscod
365347
}
366348
}
367349

350+
/**
351+
* Logs the error. Then determines what kind of error message should be shown, if
352+
* at all.
353+
*
354+
* @param error The error itself
355+
* @param topic The prefix of the error message
356+
* @param defaultMessage The message to show if once cannot be resolved from the given error
357+
*
358+
* SIDE NOTE:
359+
* This is only being used for errors from commands and webview, there's plenty of other places
360+
* (explorer, nodes, ...) where it could be used. It needs to be apart of some sort of `core`
361+
* module that is guaranteed to initialize prior to every other Toolkit component.
362+
* Logging and telemetry would fit well within this core module.
363+
*/
364+
export async function logAndShowError(error: unknown, topic: string, defaultMessage: string) {
365+
if (isUserCancelledError(error)) {
366+
getLogger().verbose(`${topic}: user cancelled`)
367+
return
368+
}
369+
const logsItem = localize('AWS.generic.message.viewLogs', 'View Logs...')
370+
const logId = getLogger().error(`${topic}: %s`, error)
371+
const message = resolveErrorMessageToDisplay(error, defaultMessage)
372+
373+
if (error instanceof ToolkitError && error.documentationUri) {
374+
await showMessageWithUrl(message, error.documentationUri, 'View Documentation', 'error')
375+
} else {
376+
await vscode.window.showErrorMessage(message, logsItem).then(async resp => {
377+
if (resp === logsItem) {
378+
await Logging.declared.viewLogsAtMessage.execute(logId)
379+
}
380+
})
381+
}
382+
}
383+
384+
/**
385+
* Show a webview related error to the user + button that links to the logged error
386+
*
387+
* @param err The error that was thrown in the backend
388+
* @param webviewId Arbitrary value that identifies which webview had the error
389+
* @param command The high level command/function that was run which triggered the error
390+
*/
391+
export function logAndShowWebviewError(err: unknown, webviewId: string, command: string) {
392+
// HACK: The following implementation is a hack, influenced by the implementation of handleError().
393+
// The userFacingError message will be seen in the UI, and the detailedError message will provide the
394+
// detailed information in the logs.
395+
const detailedError = ToolkitError.chain(err, `Webview backend command failed: "${command}()"`)
396+
const userFacingError = ToolkitError.chain(detailedError, 'Webview error')
397+
logAndShowError(userFacingError, `webviewId="${webviewId}"`, 'Webview error')
398+
}
399+
368400
// Unique extension entrypoint names, so that they can be obtained from the webpack bundle
369401
export const awsToolkitActivate = activate
370402
export const awsToolkitDeactivate = deactivate

src/shared/logger/commands.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ export class Logging {
4343
return
4444
}
4545

46+
// HACK: editor.document.getText() may return "stale" content, then
47+
// subsequent calls to openLogId() fail to highlight the specific log.
48+
// Invoke "revert" on the current file to force vscode to read from disk.
49+
await vscode.commands.executeCommand('workbench.action.files.revert')
50+
4651
// Retrieve where the message starts by counting number of newlines
4752
const text = editor.document.getText()
4853
const textStart = text.indexOf(msg)

src/shared/logger/sharedFileTransport.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export class SharedFileTransport extends TransportStream {
5252

5353
/**
5454
* @returns a promise that resolves once a batch of logs are written to
55-
* the log file.
55+
* the log file.
5656
*/
5757
public override log(logEntry: LogEntry, next: () => void): Promise<void> {
5858
this.bufferedLogEntries.push(logEntry)

src/test/webview/server.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { SinonStub, stub } from 'sinon'
7+
import { Logger, setLogger } from '../../shared/logger/logger'
8+
import assert from 'assert'
9+
import { ToolkitError } from '../../shared/errors'
10+
import { handleWebviewError } from '../../webviews/server'
11+
12+
describe('logAndShowWebviewError()', function () {
13+
let logError: SinonStub<[message: string, ...meta: any[]], number>
14+
const myWebviewId = 'myWebviewId'
15+
const myCommand = 'myCommand'
16+
17+
beforeEach(function () {
18+
logError = stub()
19+
const logger = { error: logError } as unknown as Logger
20+
setLogger(logger, 'main')
21+
})
22+
23+
afterEach(function () {
24+
setLogger(undefined, 'main')
25+
})
26+
27+
it('logs the provided error, but is wrapped in ToolkitErrors for more context', function () {
28+
// The method is being tested due to its fragile implementation. This test
29+
// protects against changes in the underlying logAndShowError() implementation.
30+
31+
const inputError = new Error('Random Error')
32+
33+
handleWebviewError(inputError, myWebviewId, myCommand)
34+
35+
assert.strictEqual(logError.callCount, 1)
36+
37+
// A shortened error is shown to the user
38+
const userFacingError = logError.getCall(0).args[1]
39+
assert(userFacingError instanceof ToolkitError)
40+
assert.strictEqual(userFacingError.message, 'Webview error')
41+
42+
// A higher level context of what caused the error
43+
const detailedError = userFacingError.cause
44+
assert(detailedError instanceof ToolkitError)
45+
assert.strictEqual(detailedError.message, `Webview backend command failed: "${myCommand}()"`)
46+
47+
// The actual error
48+
const rootError = detailedError.cause
49+
assert(rootError instanceof Error)
50+
assert.strictEqual(rootError, inputError)
51+
})
52+
})

src/webviews/main.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ export abstract class VueWebview {
227227
webviewJs: this.instance.source,
228228
...params,
229229
})
230-
const server = registerWebviewServer(panel.webview, this.instance.protocol)
230+
const server = registerWebviewServer(panel.webview, this.instance.protocol, this.instance.id)
231231
this.instance.onDidDispose(() => {
232232
server.dispose()
233233
this.panel?.dispose()
@@ -282,7 +282,11 @@ export abstract class VueWebview {
282282
if (!this.resolvedView) {
283283
this.resolvedView = view
284284

285-
const server = registerWebviewServer(this.resolvedView.webview, this.instance.protocol)
285+
const server = registerWebviewServer(
286+
this.resolvedView.webview,
287+
this.instance.protocol,
288+
this.instance.id
289+
)
286290
this.resolvedView.onDidDispose(() => {
287291
server.dispose()
288292
this.resolvedView = undefined

src/webviews/server.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,13 @@ export interface Protocol {
2121
*
2222
* @param webview Target webview to add the event hook.
2323
* @param commands Commands to register.
24+
* @param webviewId A human readable string that will identify the webview
2425
*/
25-
export function registerWebviewServer(webview: vscode.Webview, commands: Protocol): vscode.Disposable {
26+
export function registerWebviewServer(
27+
webview: vscode.Webview,
28+
commands: Protocol,
29+
webviewId: string
30+
): vscode.Disposable {
2631
const eventListeners: vscode.Disposable[] = []
2732
const disposeListeners = () => {
2833
while (eventListeners.length) {
@@ -64,13 +69,16 @@ export function registerWebviewServer(webview: vscode.Webview, commands: Protoco
6469
result = await handler.call(webview, ...data)
6570
} catch (err) {
6671
if (!(err instanceof Error)) {
67-
getLogger().debug(`Webview server threw on comamnd "${command}" but it was not an error: `, err)
72+
getLogger().debug(`Webview server threw on command "${command}" but it was not an error: `, err)
6873
return
6974
}
7075
result = JSON.stringify(err, Object.getOwnPropertyNames(err))
7176
delete result.stack // Not relevant to frontend code, we only care about the message
7277
metadata.error = true
73-
getLogger().debug(`Webview server failed on command "${command}": %s`, err.message)
78+
79+
// A command failed in the backend/server, this will surface the error to the user as a vscode error message.
80+
// This is the base error handler that will end up catching all unhandled errors.
81+
handleWebviewError(err, webviewId, command)
7482
}
7583

7684
// TODO: check if webview has been disposed of before posting message (not necessary but nice)
@@ -81,3 +89,24 @@ export function registerWebviewServer(webview: vscode.Webview, commands: Protoco
8189

8290
return { dispose: () => (messageListener.dispose(), disposeListeners()) }
8391
}
92+
93+
let errorHandler: (error: unknown, webviewId: string, command: string) => void
94+
/**
95+
* Registers the handler for errors thrown by the webview backend/server code.
96+
*/
97+
export function registerWebviewErrorHandler(handler: typeof errorHandler): void {
98+
if (errorHandler !== undefined) {
99+
throw new TypeError('Webview error handler has already been registered')
100+
}
101+
102+
errorHandler = handler
103+
}
104+
/**
105+
* Invokes the registered webview error handler.
106+
*/
107+
export function handleWebviewError(error: unknown, webviewId: string, command: string): void {
108+
if (errorHandler === undefined) {
109+
throw new Error('Webview error handler not registered')
110+
}
111+
return errorHandler(error, webviewId, command)
112+
}

src/webviews/util.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
/**
7+
* IMPORTANT:
8+
*
9+
* This class is used in the frontend vue.js webview code.
10+
* Do not put backend code in this module.
11+
*/
12+
613
import { PropType } from 'vue'
714

815
/**

0 commit comments

Comments
 (0)