Repository: aws/aws-toolkit-vscode
Pull request #7320: fix(amazonq): Sync IDE windows for Amazon Q auth state and region profile selection
Author: login: opieter-aws
Created: 2025-05-15T16:36:58Z | Merged: 2025-05-15T21:38:19Z

## 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.



Diff:
diff --git a/packages/amazonq/test/unit/codewhisperer/util/authUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/authUtil.test.ts
index 38fa1613022..97e245fecd3 100644
--- a/packages/amazonq/test/unit/codewhisperer/util/authUtil.test.ts
+++ b/packages/amazonq/test/unit/codewhisperer/util/authUtil.test.ts
@@ -140,6 +140,34 @@ describe('AuthUtil', async function () {
         })
     })
 
+    describe('cacheChangedHandler', function () {
+        it('calls logout when event is delete', async function () {
+            const logoutSpy = sinon.spy(auth, 'logout')
+
+            await (auth as any).cacheChangedHandler('delete')
+
+            assert.ok(logoutSpy.calledOnce)
+        })
+
+        it('calls restore when event is create', async function () {
+            const restoreSpy = sinon.spy(auth, 'restore')
+
+            await (auth as any).cacheChangedHandler('create')
+
+            assert.ok(restoreSpy.calledOnce)
+        })
+
+        it('does nothing for other events', async function () {
+            const logoutSpy = sinon.spy(auth, 'logout')
+            const restoreSpy = sinon.spy(auth, 'restore')
+
+            await (auth as any).cacheChangedHandler('unknown')
+
+            assert.ok(logoutSpy.notCalled)
+            assert.ok(restoreSpy.notCalled)
+        })
+    })
+
     describe('stateChangeHandler', function () {
         let mockLspAuth: any
         let regionProfileManager: any
diff --git a/packages/core/src/auth/auth2.ts b/packages/core/src/auth/auth2.ts
index 75bc7523f0c..bff664b7e7b 100644
--- a/packages/core/src/auth/auth2.ts
+++ b/packages/core/src/auth/auth2.ts
@@ -41,6 +41,7 @@ import { LanguageClient } from 'vscode-languageclient'
 import { getLogger } from '../shared/logger/logger'
 import { ToolkitError } from '../shared/errors'
 import { useDeviceFlow } from './sso/ssoAccessTokenProvider'
+import { getCacheFileWatcher } from './sso/cache'
 
 export const notificationTypes = {
     updateBearerToken: new RequestType<UpdateCredentialsParams, ResponseMessage, Error>(
@@ -66,6 +67,8 @@ interface BaseLogin {
     readonly loginType: LoginType
 }
 
+export type cacheChangedEvent = 'delete' | 'create'
+
 export type Login = SsoLogin // TODO: add IamLogin type when supported
 
 export type TokenSource = IamIdentityCenterSsoTokenSource | AwsBuilderIdSsoTokenSource
@@ -74,12 +77,18 @@ export type TokenSource = IamIdentityCenterSsoTokenSource | AwsBuilderIdSsoToken
  * Handles auth requests to the Identity Server in the Amazon Q LSP.
  */
 export class LanguageClientAuth {
+    readonly #ssoCacheWatcher = getCacheFileWatcher()
+
     constructor(
         private readonly client: LanguageClient,
         private readonly clientName: string,
         public readonly encryptionKey: Buffer
     ) {}
 
+    public get cacheWatcher() {
+        return this.#ssoCacheWatcher
+    }
+
     getSsoToken(
         tokenSource: TokenSource,
         login: boolean = false,
@@ -160,6 +169,11 @@ export class LanguageClientAuth {
     registerSsoTokenChangedHandler(ssoTokenChangedHandler: (params: SsoTokenChangedParams) => any) {
         this.client.onNotification(ssoTokenChangedRequestType.method, ssoTokenChangedHandler)
     }
+
+    registerCacheWatcher(cacheChangedHandler: (event: cacheChangedEvent) => any) {
+        this.cacheWatcher.onDidCreate(() => cacheChangedHandler('create'))
+        this.cacheWatcher.onDidDelete(() => cacheChangedHandler('delete'))
+    }
 }
 
 /**
diff --git a/packages/core/src/codewhisperer/activation.ts b/packages/core/src/codewhisperer/activation.ts
index 952edb4ac42..e11c7f5fe80 100644
--- a/packages/core/src/codewhisperer/activation.ts
+++ b/packages/core/src/codewhisperer/activation.ts
@@ -498,6 +498,7 @@ export async function activate(context: ExtContext): Promise<void> {
 export async function shutdown() {
     RecommendationHandler.instance.reportUserDecisions(-1)
     await CodeWhispererTracker.getTracker().shutdown()
+    AuthUtil.instance.regionProfileManager.globalStatePoller.kill()
 }
 
 function toggleIssuesVisibility(visibleCondition: (issue: CodeScanIssue, filePath: string) => boolean) {
diff --git a/packages/core/src/codewhisperer/region/regionProfileManager.ts b/packages/core/src/codewhisperer/region/regionProfileManager.ts
index e965052064d..4ba64570521 100644
--- a/packages/core/src/codewhisperer/region/regionProfileManager.ts
+++ b/packages/core/src/codewhisperer/region/regionProfileManager.ts
@@ -24,6 +24,7 @@ import { localize } from '../../shared/utilities/vsCodeUtils'
 import { IAuthProvider } from '../util/authUtil'
 import { Commands } from '../../shared/vscode/commands2'
 import { CachedResource } from '../../shared/utilities/resourceCache'
+import { GlobalStatePoller } from '../../shared/globalState'
 
 // TODO: is there a better way to manage all endpoint strings in one place?
 export const defaultServiceConfig: CodeWhispererConfig = {
@@ -37,6 +38,9 @@ const endpoints = createConstantMap({
     'eu-central-1': 'https://q.eu-central-1.amazonaws.com/',
 })
 
+const getRegionProfile = () =>
+    globals.globalState.tryGet<{ [label: string]: RegionProfile }>('aws.amazonq.regionProfiles', Object, {})
+
 /**
  * 'user' -> users change the profile through Q menu
  * 'auth' -> users change the profile through webview profile selector page
@@ -79,6 +83,17 @@ export class RegionProfileManager {
         }
     })(this.listRegionProfile.bind(this))
 
+    // This is a poller that handles synchornization of selected region profiles between different IDE windows.
+    // It checks for changes in global state of region profile, invoking the change handler to switch profiles
+    public globalStatePoller = GlobalStatePoller.create({
+        getState: getRegionProfile,
+        changeHandler: async () => {
+            const profile = this.loadPersistedRegionProfle()
+            void this._switchRegionProfile(profile[this.authProvider.profileName], 'reload')
+        },
+        pollIntervalInMs: 2000,
+    })
+
     get activeRegionProfile() {
         if (this.authProvider.isBuilderIdConnection()) {
             return undefined
@@ -232,6 +247,10 @@ export class RegionProfileManager {
     }
 
     private async _switchRegionProfile(regionProfile: RegionProfile | undefined, source: ProfileSwitchIntent) {
+        if (this._activeRegionProfile?.arn === regionProfile?.arn) {
+            return
+        }
+
         this._activeRegionProfile = regionProfile
 
         this._onDidChangeRegionProfile.fire({
@@ -293,13 +312,7 @@ export class RegionProfileManager {
     }
 
     private loadPersistedRegionProfle(): { [label: string]: RegionProfile } {
-        const previousPersistedState = globals.globalState.tryGet<{ [label: string]: RegionProfile }>(
-            'aws.amazonq.regionProfiles',
-            Object,
-            {}
-        )
-
-        return previousPersistedState
+        return getRegionProfile()
     }
 
     async persistSelectRegionProfile() {
@@ -309,11 +322,7 @@ export class RegionProfileManager {
         }
 
         // persist connectionId to profileArn
-        const previousPersistedState = globals.globalState.tryGet<{ [label: string]: RegionProfile }>(
-            'aws.amazonq.regionProfiles',
-            Object,
-            {}
-        )
+        const previousPersistedState = getRegionProfile()
 
         previousPersistedState[this.authProvider.profileName] = this.activeRegionProfile
         await globals.globalState.update('aws.amazonq.regionProfiles', previousPersistedState)
diff --git a/packages/core/src/codewhisperer/util/authUtil.ts b/packages/core/src/codewhisperer/util/authUtil.ts
index eac12d3cf80..ec9a2ff91e1 100644
--- a/packages/core/src/codewhisperer/util/authUtil.ts
+++ b/packages/core/src/codewhisperer/util/authUtil.ts
@@ -31,7 +31,7 @@ import { showAmazonQWalkthroughOnce } from '../../amazonq/onboardingPage/walkthr
 import { setContext } from '../../shared/vscode/setContext'
 import { openUrl } from '../../shared/utilities/vsCodeUtils'
 import { telemetry } from '../../shared/telemetry/telemetry'
-import { AuthStateEvent, LanguageClientAuth, LoginTypes, SsoLogin } from '../../auth/auth2'
+import { AuthStateEvent, cacheChangedEvent, LanguageClientAuth, LoginTypes, SsoLogin } from '../../auth/auth2'
 import { builderIdStartUrl, internalStartUrl } from '../../auth/sso/constants'
 import { VSCODE_EXTENSION_ID } from '../../shared/extensions'
 import { RegionProfileManager } from '../region/regionProfileManager'
@@ -90,6 +90,7 @@ export class AuthUtil implements IAuthProvider {
         this.regionProfileManager.onDidChangeRegionProfile(async () => {
             await this.setVscodeContextProps()
         })
+        lspAuth.registerCacheWatcher(async (event: cacheChangedEvent) => await this.cacheChangedHandler(event))
     }
 
     // Do NOT use this in production code, only used for testing
@@ -276,6 +277,14 @@ export class AuthUtil implements IAuthProvider {
         })
     }
 
+    private async cacheChangedHandler(event: cacheChangedEvent) {
+        if (event === 'delete') {
+            await this.logout()
+        } else if (event === 'create') {
+            await this.restore()
+        }
+    }
+
     private async stateChangeHandler(e: AuthStateEvent) {
         if (e.state === 'refreshed') {
             const params = this.isSsoSession() ? (await this.session.getToken()).updateCredentialsParams : undefined
diff --git a/packages/core/src/shared/globalState.ts b/packages/core/src/shared/globalState.ts
index c38344608b9..65d761412b8 100644
--- a/packages/core/src/shared/globalState.ts
+++ b/packages/core/src/shared/globalState.ts
@@ -318,3 +318,68 @@ export class GlobalState implements vscode.Memento {
         return all?.[id]
     }
 }
+
+export interface GlobalStatePollerProps {
+    getState: () => any
+    changeHandler: () => void
+    pollIntervalInMs: number
+}
+
+/**
+ * Utility class that polls a state value at regular intervals and triggers a callback when the state changes.
+ *
+ * This class can be used to monitor changes in global state and react to those changes.
+ */
+export class GlobalStatePoller {
+    protected oldValue: any
+    protected pollIntervalInMs: number
+    protected getState: () => any
+    protected changeHandler: () => void
+    protected intervalId?: NodeJS.Timeout
+
+    constructor(props: GlobalStatePollerProps) {
+        this.getState = props.getState
+        this.changeHandler = props.changeHandler
+        this.pollIntervalInMs = props.pollIntervalInMs
+        this.oldValue = this.getState()
+    }
+
+    /**
+     * Factory method that creates and starts a GlobalStatePoller instance.
+     *
+     * @param getState - Function that returns the current state value to monitor, e.g. globals.globalState.tryGet
+     * @param changeHandler - Callback function that is invoked when the state changes
+     * @returns A new GlobalStatePoller instance that has already started polling
+     */
+    static create(props: GlobalStatePollerProps) {
+        const instance = new GlobalStatePoller(props)
+        instance.poll()
+        return instance
+    }
+
+    /**
+     * Starts polling the state value. When a change is detected, the changeHandler callback is invoked.
+     */
+    private poll() {
+        if (this.intervalId) {
+            this.kill()
+        }
+        this.intervalId = setInterval(() => {
+            const newValue = this.getState()
+            if (this.oldValue !== newValue) {
+                this.oldValue = newValue
+                this.changeHandler()
+            }
+        }, this.pollIntervalInMs)
+    }
+
+    /**
+     * Stops the polling interval.
+     */
+    kill() {
+        if (this.intervalId) {
+            clearInterval(this.intervalId)
+            this.intervalId = undefined
+        }
+    }
+}
diff --git a/packages/core/src/test/testAuthUtil.ts b/packages/core/src/test/testAuthUtil.ts
index 4feefec2d68..595f8bf45ef 100644
--- a/packages/core/src/test/testAuthUtil.ts
+++ b/packages/core/src/test/testAuthUtil.ts
@@ -36,6 +36,7 @@ export async function createTestAuthUtil() {
         deleteBearerToken: sinon.stub().resolves(),
         updateBearerToken: sinon.stub().resolves(),
         invalidateSsoToken: sinon.stub().resolves(),
+        registerCacheWatcher: sinon.stub().resolves(),
         encryptionKey,
     }
 


Reviews:
user: login: hayemaxi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T17:38:25Z
user: login: hayemaxi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T17:39:31Z
user: login: nkomonen-amazon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T18:51:06Z
user: login: nkomonen-amazon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T18:54:03Z
user: login: hayemaxi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T20:17:01Z
user: login: hayemaxi
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T20:22:13Z

Review comments:
diff_hunk: @@ -318,3 +318,48 @@ export class GlobalState implements vscode.Memento {
         return all?.[id]
     }
 }
+
+/**
+ * Utility class that polls a state value at regular intervals and triggers a callback when the state changes.
+ *
+ * This class can be used to monitor changes in global state and react to those changes.
+ */
+export class GlobalStatePoller {
+    protected oldValue: any
+    protected getState: () => any
+    protected changeHandler: () => void
+
+    constructor(getState: () => any, changeHandler: () => void) {
+        this.getState = getState
+        this.changeHandler = changeHandler
+        this.oldValue = getState()
+    }
+
+    /**
+     * Factory method that creates and starts a GlobalStatePoller instance.
+     *
+     * @param getState - Function that returns the current state value to monitor, e.g. globals.globalState.tryGet
+     * @param changeHandler - Callback function that is invoked when the state changes
+     * @returns A new GlobalStatePoller instance that has already started polling
+     */
+    static create(getState: () => any, changeHandler: () => void) {
+        const instance = new GlobalStatePoller(getState, changeHandler)
+        instance.poll()
+        return instance
+    }
+
+    /**
+     * Starts polling the state value at 1 second intervals.
+     * When a change is detected, the changeHandler callback is invoked.
+     */
+    poll() {
+        const interval = 1000 // ms
+        setInterval(() => {
path: packages/core/src/shared/globalState.ts
user: login: hayemaxi
body: You should store this interval in the class, and have a method to kill the interval (turn off the watcher). We can call this on extension deactivation.
created_at: 2025-05-15T17:38:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -318,3 +318,48 @@ export class GlobalState implements vscode.Memento {
         return all?.[id]
     }
 }
+
+/**
+ * Utility class that polls a state value at regular intervals and triggers a callback when the state changes.
+ *
+ * This class can be used to monitor changes in global state and react to those changes.
+ */
+export class GlobalStatePoller {
+    protected oldValue: any
+    protected getState: () => any
+    protected changeHandler: () => void
+
+    constructor(getState: () => any, changeHandler: () => void) {
+        this.getState = getState
+        this.changeHandler = changeHandler
+        this.oldValue = getState()
+    }
+
+    /**
+     * Factory method that creates and starts a GlobalStatePoller instance.
+     *
+     * @param getState - Function that returns the current state value to monitor, e.g. globals.globalState.tryGet
+     * @param changeHandler - Callback function that is invoked when the state changes
+     * @returns A new GlobalStatePoller instance that has already started polling
+     */
+    static create(getState: () => any, changeHandler: () => void) {
+        const instance = new GlobalStatePoller(getState, changeHandler)
+        instance.poll()
+        return instance
+    }
+
+    /**
+     * Starts polling the state value at 1 second intervals.
+     * When a change is detected, the changeHandler callback is invoked.
+     */
+    poll() {
path: packages/core/src/shared/globalState.ts
user: login: hayemaxi
body: ```suggestion
    private poll() {
```
created_at: 2025-05-15T17:39:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,7 +121,18 @@ export class RegionProfileManager {
         return this._profiles
     }
 
-    constructor(private readonly authProvider: IAuthProvider) {}
+    constructor(private readonly authProvider: IAuthProvider) {
+        const getProfileFunction = () =>
+            globals.globalState.tryGet<{ [label: string]: RegionProfile }>('aws.amazonq.regionProfiles', Object, {})
path: packages/core/src/codewhisperer/region/regionProfileManager.ts
user: login: nkomonen-amazon
body: There are multiple instances of this tryGet in this file. Can we make a single private method and just call that
created_at: 2025-05-15T18:51:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -276,6 +277,14 @@ export class AuthUtil implements IAuthProvider {
         })
     }
 
+    private async cacheChangedHandler(event: string) {
path: packages/core/src/codewhisperer/util/authUtil.ts
user: login: nkomonen-amazon
body: Can we enumerate the type of `event` to `'create' | 'delete'`
created_at: 2025-05-15T18:54:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -318,3 +318,65 @@ export class GlobalState implements vscode.Memento {
         return all?.[id]
     }
 }
+
+export interface GlobalStatePollerProps {
+    getState: () => any
+    changeHandler: () => void
+    pollIntervalInMs: number
+}
+
+/**
+ * Utility class that polls a state value at regular intervals and triggers a callback when the state changes.
+ *
+ * This class can be used to monitor changes in global state and react to those changes.
+ */
+export class GlobalStatePoller {
+    protected oldValue: any
+    protected pollIntervalInMs: number
+    protected getState: () => any
+    protected changeHandler: () => void
+    protected intervalId?: NodeJS.Timeout
+
+    constructor(props: GlobalStatePollerProps) {
+        this.getState = props.getState
+        this.changeHandler = props.changeHandler
+        this.pollIntervalInMs = props.pollIntervalInMs
+        this.oldValue = this.getState()
+    }
+
+    /**
+     * Factory method that creates and starts a GlobalStatePoller instance.
+     *
+     * @param getState - Function that returns the current state value to monitor, e.g. globals.globalState.tryGet
+     * @param changeHandler - Callback function that is invoked when the state changes
+     * @returns A new GlobalStatePoller instance that has already started polling
+     */
+    static create(props: GlobalStatePollerProps) {
+        const instance = new GlobalStatePoller(props)
+        instance.poll()
+        return instance
+    }
+
+    /**
+     * Starts polling the state value. When a change is detected, the changeHandler callback is invoked.
+     */
+    private poll() {
+        this.intervalId = setInterval(() => {
path: packages/core/src/shared/globalState.ts
user: login: hayemaxi
body: should probably call `kill()` at the start of this function as a safety check
created_at: 2025-05-15T20:17:01Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-15T16:37:11Z
body: - This pull request modifies code in `src/*` but no tests were added/updated.
    - Confirm whether tests should be added or ensure the PR description explains why tests are not required.

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-15T16:37:54Z
body: - This pull request modifies code in `src/*` but no tests were added/updated.
    - Confirm whether tests should be added or ensure the PR description explains why tests are not required.
- This pull request implements a `feat` or `fix`, so it must include a changelog entry (unless the fix is for an *unreleased* feature). Review the [changelog guidelines](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#changelog).
    - Note: beta or "experiment" features that have active users *should* announce fixes in the changelog.
    - If this is not a feature or fix, use an appropriate type from the [title guidelines](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#pull-request-title). For example, telemetry-only changes should use the `telemetry` type.

author_association: NONE
user: login: hayemaxi
created_at: 2025-05-15T17:35:16Z
body: Very nice. Does it work if you say, select the profile in the other window that you weren't using to log in?
author_association: CONTRIBUTOR
user: login: hayemaxi
created_at: 2025-05-15T17:36:06Z
body: Do we need to poll **every** second? Maybe every 2 or 3 seconds?
author_association: CONTRIBUTOR
user: login: opieter-aws
created_at: 2025-05-15T18:15:31Z
body: > Do we need to poll **every** second? Maybe every 2 or 3 seconds?

The behavior gets awkward when you are logged out and log back in; the 'other' window will keep the region selector displayed until the sync. I can increase to 2 seconds, but I don't think we want to keep that state much longer

> Very nice. Does it work if you say, select the profile in the other window that you weren't using to log in?

Yes it behaves the same!
author_association: CONTRIBUTOR
user: login: nkomonen-amazon
created_at: 2025-05-15T18:40:16Z
body: One thing to note with this polling length. We should think of realistic user behaviors and if we set polling to 2-3 seconds, would they even notice it? Assuming they sign in or out on one IDE instance, it is low likelihood they are switching to the other IDE very quickly.

It is also unlikely that the user will select the profile in one window and then immediately switch to the other.
author_association: CONTRIBUTOR
user: login: hayemaxi
created_at: 2025-05-15T19:10:23Z
body: > One thing to note with this polling length. We should think of realistic user behaviors and if we set polling to 2-3 seconds, would they even notice it? Assuming they sign in or out on one IDE instance, it is low likelihood they are switching to the other IDE very quickly.
> 
> It is also unlikely that the user will select the profile in one window and then immediately switch to the other.

+1

No one is comparing how smooth the auth change is side-by-side.
author_association: CONTRIBUTOR
user: login: Will-ShaoHua
created_at: 2025-05-15T20:38:13Z
body: thanks for this change, it's really nice. Maybe we should bring this to mainline as well
author_association: CONTRIBUTOR
user: login: opieter-aws
created_at: 2025-05-15T21:13:47Z
body: > Maybe we should bring this to mainline as well

We won't be adding features for auth to mainline for now, but instead prioritize releasing this branch with auth on LSP identity server
author_association: CONTRIBUTOR