Skip to content

Commit ed4b265

Browse files
authored
fix(amazonq): Sync IDE windows for Amazon Q auth state and region profile selection (#7320)
## Problem The auth state and region profile selection is not sycned between different IDE windows ## Solution #### Auth state * Add an `ssoCacheWatcher` to the LSP auth client, with hooks on `onDidCreate` and `onDidDelete` * In the `AuthUtil`, add the following handlers: * `onDidCreate`: trigger a restore flow to fetch the latest auth state * `onDidDelete`: trigger a logout #### Region profile * Add a `GlobalStatePoller` util that polls the global state value every second, and call a handler if the value updates * In `RegionProfileManager` add the poller for the region profile global state variable and add a handler that switches the profile ## Testing https://github.com/user-attachments/assets/8cdf6abd-94bd-4e60-9bcf-a60f42d1847e --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent 1d336f1 commit ed4b265

File tree

7 files changed

+140
-13
lines changed

7 files changed

+140
-13
lines changed

packages/amazonq/test/unit/codewhisperer/util/authUtil.test.ts

+28
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,34 @@ describe('AuthUtil', async function () {
140140
})
141141
})
142142

143+
describe('cacheChangedHandler', function () {
144+
it('calls logout when event is delete', async function () {
145+
const logoutSpy = sinon.spy(auth, 'logout')
146+
147+
await (auth as any).cacheChangedHandler('delete')
148+
149+
assert.ok(logoutSpy.calledOnce)
150+
})
151+
152+
it('calls restore when event is create', async function () {
153+
const restoreSpy = sinon.spy(auth, 'restore')
154+
155+
await (auth as any).cacheChangedHandler('create')
156+
157+
assert.ok(restoreSpy.calledOnce)
158+
})
159+
160+
it('does nothing for other events', async function () {
161+
const logoutSpy = sinon.spy(auth, 'logout')
162+
const restoreSpy = sinon.spy(auth, 'restore')
163+
164+
await (auth as any).cacheChangedHandler('unknown')
165+
166+
assert.ok(logoutSpy.notCalled)
167+
assert.ok(restoreSpy.notCalled)
168+
})
169+
})
170+
143171
describe('stateChangeHandler', function () {
144172
let mockLspAuth: any
145173
let regionProfileManager: any

packages/core/src/auth/auth2.ts

+14
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { LanguageClient } from 'vscode-languageclient'
4141
import { getLogger } from '../shared/logger/logger'
4242
import { ToolkitError } from '../shared/errors'
4343
import { useDeviceFlow } from './sso/ssoAccessTokenProvider'
44+
import { getCacheFileWatcher } from './sso/cache'
4445

4546
export const notificationTypes = {
4647
updateBearerToken: new RequestType<UpdateCredentialsParams, ResponseMessage, Error>(
@@ -66,6 +67,8 @@ interface BaseLogin {
6667
readonly loginType: LoginType
6768
}
6869

70+
export type cacheChangedEvent = 'delete' | 'create'
71+
6972
export type Login = SsoLogin // TODO: add IamLogin type when supported
7073

7174
export type TokenSource = IamIdentityCenterSsoTokenSource | AwsBuilderIdSsoTokenSource
@@ -74,12 +77,18 @@ export type TokenSource = IamIdentityCenterSsoTokenSource | AwsBuilderIdSsoToken
7477
* Handles auth requests to the Identity Server in the Amazon Q LSP.
7578
*/
7679
export class LanguageClientAuth {
80+
readonly #ssoCacheWatcher = getCacheFileWatcher()
81+
7782
constructor(
7883
private readonly client: LanguageClient,
7984
private readonly clientName: string,
8085
public readonly encryptionKey: Buffer
8186
) {}
8287

88+
public get cacheWatcher() {
89+
return this.#ssoCacheWatcher
90+
}
91+
8392
getSsoToken(
8493
tokenSource: TokenSource,
8594
login: boolean = false,
@@ -160,6 +169,11 @@ export class LanguageClientAuth {
160169
registerSsoTokenChangedHandler(ssoTokenChangedHandler: (params: SsoTokenChangedParams) => any) {
161170
this.client.onNotification(ssoTokenChangedRequestType.method, ssoTokenChangedHandler)
162171
}
172+
173+
registerCacheWatcher(cacheChangedHandler: (event: cacheChangedEvent) => any) {
174+
this.cacheWatcher.onDidCreate(() => cacheChangedHandler('create'))
175+
this.cacheWatcher.onDidDelete(() => cacheChangedHandler('delete'))
176+
}
163177
}
164178

165179
/**

packages/core/src/codewhisperer/activation.ts

+1
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ export async function activate(context: ExtContext): Promise<void> {
498498
export async function shutdown() {
499499
RecommendationHandler.instance.reportUserDecisions(-1)
500500
await CodeWhispererTracker.getTracker().shutdown()
501+
AuthUtil.instance.regionProfileManager.globalStatePoller.kill()
501502
}
502503

503504
function toggleIssuesVisibility(visibleCondition: (issue: CodeScanIssue, filePath: string) => boolean) {

packages/core/src/codewhisperer/region/regionProfileManager.ts

+21-12
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { localize } from '../../shared/utilities/vsCodeUtils'
2424
import { IAuthProvider } from '../util/authUtil'
2525
import { Commands } from '../../shared/vscode/commands2'
2626
import { CachedResource } from '../../shared/utilities/resourceCache'
27+
import { GlobalStatePoller } from '../../shared/globalState'
2728

2829
// TODO: is there a better way to manage all endpoint strings in one place?
2930
export const defaultServiceConfig: CodeWhispererConfig = {
@@ -37,6 +38,9 @@ const endpoints = createConstantMap({
3738
'eu-central-1': 'https://q.eu-central-1.amazonaws.com/',
3839
})
3940

41+
const getRegionProfile = () =>
42+
globals.globalState.tryGet<{ [label: string]: RegionProfile }>('aws.amazonq.regionProfiles', Object, {})
43+
4044
/**
4145
* 'user' -> users change the profile through Q menu
4246
* 'auth' -> users change the profile through webview profile selector page
@@ -79,6 +83,17 @@ export class RegionProfileManager {
7983
}
8084
})(this.listRegionProfile.bind(this))
8185

86+
// This is a poller that handles synchornization of selected region profiles between different IDE windows.
87+
// It checks for changes in global state of region profile, invoking the change handler to switch profiles
88+
public globalStatePoller = GlobalStatePoller.create({
89+
getState: getRegionProfile,
90+
changeHandler: async () => {
91+
const profile = this.loadPersistedRegionProfle()
92+
void this._switchRegionProfile(profile[this.authProvider.profileName], 'reload')
93+
},
94+
pollIntervalInMs: 2000,
95+
})
96+
8297
get activeRegionProfile() {
8398
if (this.authProvider.isBuilderIdConnection()) {
8499
return undefined
@@ -232,6 +247,10 @@ export class RegionProfileManager {
232247
}
233248

234249
private async _switchRegionProfile(regionProfile: RegionProfile | undefined, source: ProfileSwitchIntent) {
250+
if (this._activeRegionProfile?.arn === regionProfile?.arn) {
251+
return
252+
}
253+
235254
this._activeRegionProfile = regionProfile
236255

237256
this._onDidChangeRegionProfile.fire({
@@ -293,13 +312,7 @@ export class RegionProfileManager {
293312
}
294313

295314
private loadPersistedRegionProfle(): { [label: string]: RegionProfile } {
296-
const previousPersistedState = globals.globalState.tryGet<{ [label: string]: RegionProfile }>(
297-
'aws.amazonq.regionProfiles',
298-
Object,
299-
{}
300-
)
301-
302-
return previousPersistedState
315+
return getRegionProfile()
303316
}
304317

305318
async persistSelectRegionProfile() {
@@ -309,11 +322,7 @@ export class RegionProfileManager {
309322
}
310323

311324
// persist connectionId to profileArn
312-
const previousPersistedState = globals.globalState.tryGet<{ [label: string]: RegionProfile }>(
313-
'aws.amazonq.regionProfiles',
314-
Object,
315-
{}
316-
)
325+
const previousPersistedState = getRegionProfile()
317326

318327
previousPersistedState[this.authProvider.profileName] = this.activeRegionProfile
319328
await globals.globalState.update('aws.amazonq.regionProfiles', previousPersistedState)

packages/core/src/codewhisperer/util/authUtil.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { showAmazonQWalkthroughOnce } from '../../amazonq/onboardingPage/walkthr
3131
import { setContext } from '../../shared/vscode/setContext'
3232
import { openUrl } from '../../shared/utilities/vsCodeUtils'
3333
import { telemetry } from '../../shared/telemetry/telemetry'
34-
import { AuthStateEvent, LanguageClientAuth, LoginTypes, SsoLogin } from '../../auth/auth2'
34+
import { AuthStateEvent, cacheChangedEvent, LanguageClientAuth, LoginTypes, SsoLogin } from '../../auth/auth2'
3535
import { builderIdStartUrl, internalStartUrl } from '../../auth/sso/constants'
3636
import { VSCODE_EXTENSION_ID } from '../../shared/extensions'
3737
import { RegionProfileManager } from '../region/regionProfileManager'
@@ -90,6 +90,7 @@ export class AuthUtil implements IAuthProvider {
9090
this.regionProfileManager.onDidChangeRegionProfile(async () => {
9191
await this.setVscodeContextProps()
9292
})
93+
lspAuth.registerCacheWatcher(async (event: cacheChangedEvent) => await this.cacheChangedHandler(event))
9394
}
9495

9596
// Do NOT use this in production code, only used for testing
@@ -276,6 +277,14 @@ export class AuthUtil implements IAuthProvider {
276277
})
277278
}
278279

280+
private async cacheChangedHandler(event: cacheChangedEvent) {
281+
if (event === 'delete') {
282+
await this.logout()
283+
} else if (event === 'create') {
284+
await this.restore()
285+
}
286+
}
287+
279288
private async stateChangeHandler(e: AuthStateEvent) {
280289
if (e.state === 'refreshed') {
281290
const params = this.isSsoSession() ? (await this.session.getToken()).updateCredentialsParams : undefined

packages/core/src/shared/globalState.ts

+65
Original file line numberDiff line numberDiff line change
@@ -318,3 +318,68 @@ export class GlobalState implements vscode.Memento {
318318
return all?.[id]
319319
}
320320
}
321+
322+
export interface GlobalStatePollerProps {
323+
getState: () => any
324+
changeHandler: () => void
325+
pollIntervalInMs: number
326+
}
327+
328+
/**
329+
* Utility class that polls a state value at regular intervals and triggers a callback when the state changes.
330+
*
331+
* This class can be used to monitor changes in global state and react to those changes.
332+
*/
333+
export class GlobalStatePoller {
334+
protected oldValue: any
335+
protected pollIntervalInMs: number
336+
protected getState: () => any
337+
protected changeHandler: () => void
338+
protected intervalId?: NodeJS.Timeout
339+
340+
constructor(props: GlobalStatePollerProps) {
341+
this.getState = props.getState
342+
this.changeHandler = props.changeHandler
343+
this.pollIntervalInMs = props.pollIntervalInMs
344+
this.oldValue = this.getState()
345+
}
346+
347+
/**
348+
* Factory method that creates and starts a GlobalStatePoller instance.
349+
*
350+
* @param getState - Function that returns the current state value to monitor, e.g. globals.globalState.tryGet
351+
* @param changeHandler - Callback function that is invoked when the state changes
352+
* @returns A new GlobalStatePoller instance that has already started polling
353+
*/
354+
static create(props: GlobalStatePollerProps) {
355+
const instance = new GlobalStatePoller(props)
356+
instance.poll()
357+
return instance
358+
}
359+
360+
/**
361+
* Starts polling the state value. When a change is detected, the changeHandler callback is invoked.
362+
*/
363+
private poll() {
364+
if (this.intervalId) {
365+
this.kill()
366+
}
367+
this.intervalId = setInterval(() => {
368+
const newValue = this.getState()
369+
if (this.oldValue !== newValue) {
370+
this.oldValue = newValue
371+
this.changeHandler()
372+
}
373+
}, this.pollIntervalInMs)
374+
}
375+
376+
/**
377+
* Stops the polling interval.
378+
*/
379+
kill() {
380+
if (this.intervalId) {
381+
clearInterval(this.intervalId)
382+
this.intervalId = undefined
383+
}
384+
}
385+
}

packages/core/src/test/testAuthUtil.ts

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export async function createTestAuthUtil() {
3636
deleteBearerToken: sinon.stub().resolves(),
3737
updateBearerToken: sinon.stub().resolves(),
3838
invalidateSsoToken: sinon.stub().resolves(),
39+
registerCacheWatcher: sinon.stub().resolves(),
3940
encryptionKey,
4041
}
4142

0 commit comments

Comments
 (0)