Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(notifications): extension activation hangs if fetch fails #6052

Merged
merged 2 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/amazonq/src/extensionNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ async function activateAmazonQNode(context: vscode.ExtensionContext) {
...authState,
})

await activateNotifications(context, authState, getAuthState)
void activateNotifications(context, authState, getAuthState)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if possible it's helpful to make activateNotications non-async, so that it clearly signals that it does its own logging and .catch() of async functions itself.

}

async function getAuthState(): Promise<Omit<AuthUserState, 'source'>> {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/extensionNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export async function activate(context: vscode.ExtensionContext) {
...authState,
})

await activateNotifications(context, authState, getAuthState)
void activateNotifications(context, authState, getAuthState)
} catch (error) {
const stacktrace = (error as Error).stack?.split('\n')
// truncate if the stacktrace is unusually long
Expand Down
28 changes: 17 additions & 11 deletions packages/core/src/notifications/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { AuthState } from './types'
import { getLogger } from '../shared/logger/logger'
import { oneMinute } from '../shared/datetime'

const logger = getLogger('notifications')

/** Time in MS to poll for emergency notifications */
const emergencyPollTime = oneMinute * 10

Expand All @@ -33,19 +35,23 @@ export async function activate(
return
}

const panelNode = NotificationsNode.instance
panelNode.registerView(context)
try {
const panelNode = NotificationsNode.instance
panelNode.registerView(context)

const controller = new NotificationsController(panelNode)
const engine = new RuleEngine(await getRuleContext(context, initialState))
const controller = new NotificationsController(panelNode)
const engine = new RuleEngine(await getRuleContext(context, initialState))

await controller.pollForStartUp(engine)
await controller.pollForEmergencies(engine)
await controller.pollForStartUp(engine)
await controller.pollForEmergencies(engine)

globals.clock.setInterval(async () => {
const ruleContext = await getRuleContext(context, await authStateFn())
await controller.pollForEmergencies(new RuleEngine(ruleContext))
}, emergencyPollTime)
globals.clock.setInterval(async () => {
const ruleContext = await getRuleContext(context, await authStateFn())
await controller.pollForEmergencies(new RuleEngine(ruleContext))
}, emergencyPollTime)

getLogger('notifications').debug('Activated in-IDE notifications polling module')
logger.debug('Activated in-IDE notifications polling module')
} catch (err) {
logger.error('Failed to activate in-IDE notifications module.')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.... %O', err)`

or err.message

}
}
81 changes: 40 additions & 41 deletions packages/core/src/notifications/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetch
import { isAmazonQ } from '../shared/extensionUtilities'
import { telemetry } from '../shared/telemetry/telemetry'

const logger = getLogger('notifications')

/**
* Handles fetching and maintaining the state of in-IDE notifications.
* Notifications are constantly polled from a known endpoint and then stored in global state.
Expand All @@ -39,8 +41,6 @@ import { telemetry } from '../shared/telemetry/telemetry'
* Emergency notifications - fetched at a regular interval.
*/
export class NotificationsController {
public static readonly suggestedPollIntervalMs = 1000 * 60 * 10 // 10 minutes

/** Internal memory state that is written to global state upon modification. */
private readonly state: NotificationsState

Expand Down Expand Up @@ -75,7 +75,7 @@ export class NotificationsController {
this.readState()
await this.fetchNotifications(category)
} catch (err: any) {
getLogger('notifications').error(`Unable to fetch %s notifications: %s`, category, err)
logger.error(`Unable to fetch %s notifications: %s`, category, err)
}

await this.displayNotifications(ruleEngine)
Expand All @@ -93,25 +93,20 @@ export class NotificationsController {

await NotificationsNode.instance.setNotifications(startUp, emergency)

// Emergency notifications can't be dismissed, but if the user minimizes the panel then
// we don't want to focus it each time we set the notification nodes.
// So we store it in dismissed once a focus has been fired for it.
const newEmergencies = emergency.map((n) => n.id).filter((id) => !dismissed.has(id))
if (newEmergencies.length > 0) {
this.state.dismissed = [...this.state.dismissed, ...newEmergencies]
await this.writeState()
void this.notificationsNode.focusPanel()
}

// Process on-receive behavior for newly received notifications that passes rule engine
const newlyReceivedToDisplay = [...startUp, ...emergency].filter((n) => this.state.newlyReceived.includes(n.id))
if (newlyReceivedToDisplay.length > 0) {
await this.notificationsNode.onReceiveNotifications(newlyReceivedToDisplay)
const wasNewlyReceived = (n: ToolkitNotification) => this.state.newlyReceived.includes(n.id)
const newStartUp = startUp.filter(wasNewlyReceived)
const newEmergency = emergency.filter(wasNewlyReceived)
const newlyReceived = [...newStartUp, ...newEmergency]

if (newlyReceived.length > 0) {
await this.notificationsNode.onReceiveNotifications(newlyReceived)
// remove displayed notifications from newlyReceived
this.state.newlyReceived = this.state.newlyReceived.filter(
(id) => !newlyReceivedToDisplay.some((n) => n.id === id)
)
this.state.newlyReceived = this.state.newlyReceived.filter((id) => !newlyReceived.some((n) => n.id === id))
await this.writeState()
if (newEmergency.length > 0) {
void this.notificationsNode.focusPanel()
}
}
}

Expand All @@ -121,7 +116,9 @@ export class NotificationsController {
* hide all notifications.
*/
public async dismissNotification(notificationId: string) {
getLogger('notifications').debug('Dismissing notification: %s', notificationId)
logger.debug('Dismissing notification: %s', notificationId)

this.readState() // Don't overwrite dismissals from other windows
this.state.dismissed.push(notificationId)
await this.writeState()

Expand All @@ -134,7 +131,7 @@ export class NotificationsController {
private async fetchNotifications(category: NotificationType) {
const response = await this.fetcher.fetch(category, this.state[category].eTag)
if (!response.content) {
getLogger('notifications').verbose('No new notifications for category: %s', category)
logger.verbose('No new notifications for category: %s', category)
return
}
// Parse the notifications
Expand All @@ -149,7 +146,7 @@ export class NotificationsController {
const addedNotifications = newNotifications.filter((n: any) => !currentNotificationIds.has(n.id))

if (addedNotifications.length > 0) {
getLogger('notifications').verbose(
logger.verbose(
'New notifications received for category %s, ids: %s',
category,
addedNotifications.map((n: any) => n.id).join(', ')
Expand All @@ -161,7 +158,7 @@ export class NotificationsController {
this.state[category].eTag = response.eTag
await this.writeState()

getLogger('notifications').verbose(
logger.verbose(
"Fetched notifications JSON for category '%s' with schema version: %s. There were %d notifications.",
category,
this.state[category].payload?.schemaVersion,
Expand All @@ -173,7 +170,7 @@ export class NotificationsController {
* Write the latest memory state to global state.
*/
private async writeState() {
getLogger('notifications').debug('NotificationsController: Updating notifications state at %s', this.storageKey)
logger.debug('NotificationsController: Updating notifications state at %s', this.storageKey)

// Clean out anything in 'dismissed' that doesn't exist anymore.
const notifications = new Set(
Expand All @@ -199,7 +196,7 @@ export class NotificationsController {
*/
private readState() {
const state = this.getDefaultState()
this.state.dismissed = state.dismissed
this.state.dismissed = [...new Set([...this.state.dismissed, ...state.dismissed])]
}

/**
Expand Down Expand Up @@ -239,7 +236,7 @@ function registerDismissCommand() {
await NotificationsController.instance.dismissNotification(notification.id)
})
} else {
getLogger('notifications').error(`${name}: Cannot dismiss notification: item is not a vscode.TreeItem`)
logger.error(`${name}: Cannot dismiss notification: item is not a vscode.TreeItem`)
}
})
)
Expand Down Expand Up @@ -277,17 +274,23 @@ export class RemoteFetcher implements NotificationFetcher {
const fetcher = new HttpResourceFetcher(endpoint, {
showUrl: true,
})
getLogger('notifications').verbose(
'Attempting to fetch notifications for category: %s at endpoint: %s',
category,
endpoint
logger.verbose('Attempting to fetch notifications for category: %s at endpoint: %s', category, endpoint)

return withRetries(
async () => {
try {
return await fetcher.getNewETagContent(versionTag)
} catch (err) {
logger.error('Failed to fetch at endpoint: %s, err: %s', endpoint, err)
throw err
Comment on lines +279 to +285
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

withRetries should do useful logging of failure by default. We have so much nesting of these wrapper functions, we might as well do something with it...

}
},
{
maxRetries: RemoteFetcher.retryNumber,
delay: RemoteFetcher.retryIntervalMs,
// No exponential backoff - necessary?
}
)

return withRetries(async () => await fetcher.getNewETagContent(versionTag), {
maxRetries: RemoteFetcher.retryNumber,
delay: RemoteFetcher.retryIntervalMs,
// No exponential backoff - necessary?
})
}
}

Expand All @@ -309,11 +312,7 @@ export class LocalFetcher implements NotificationFetcher {

async fetch(category: NotificationType, versionTag?: string): Promise<ResourceResponse> {
const uri = category === 'startUp' ? this.startUpLocalPath : this.emergencyLocalPath
getLogger('notifications').verbose(
'Attempting to fetch notifications locally for category: %s at path: %s',
category,
uri
)
logger.verbose('Attempting to fetch notifications locally for category: %s at path: %s', category, uri)

return {
content: await new FileResourceFetcher(globals.context.asAbsolutePath(uri)).get(),
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/test/notifications/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ describe('Notifications Controller', function () {
payload: content,
eTag,
},
dismissed: [content.notifications[0].id],
dismissed: [],
newlyReceived: ['id:emergency2'],
})
assert.equal(panelNode.startUpNotifications.length, 0)
Expand Down Expand Up @@ -218,7 +218,7 @@ describe('Notifications Controller', function () {
payload: emergencyContent,
eTag: eTag2,
},
dismissed: [emergencyContent.notifications[0].id],
dismissed: [],
newlyReceived: ['id:startup2', 'id:emergency2'],
})
assert.equal(panelNode.startUpNotifications.length, 1)
Expand Down Expand Up @@ -415,7 +415,7 @@ describe('Notifications Controller', function () {
payload: emergencyContent,
eTag: '1',
},
dismissed: [emergencyContent.notifications[0].id, startUpContent.notifications[0].id],
dismissed: [startUpContent.notifications[0].id],
newlyReceived: [],
})

Expand All @@ -438,7 +438,7 @@ describe('Notifications Controller', function () {
payload: emergencyContent,
eTag: '1',
},
dismissed: [emergencyContent.notifications[0].id],
dismissed: [],
newlyReceived: [],
})
assert.equal(panelNode.getChildren().length, 1)
Expand Down
Loading