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: race condition in saving history before switching accounts #6487

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

arafatkatze
Copy link
Collaborator

@arafatkatze arafatkatze commented Dec 29, 2024

Linear issue

Problem

When switching between Cody accounts, the last active chat would incorrectly appear in both accounts' chat histories. This happened because:

  1. During account switching, disposeAllChats() was called which attempts to save the current chat state
  2. However, by the time disposeAllChats() runs, the currentAuthAccount had already been updated to the new account
  3. This caused the chat to be saved under the wrong account's history

Solution

Separated the logout and account switch logic:

  • For logout: Continue to call disposeAllChats() to properly save and clear chats
  • For account switch: Skip disposeAllChats() and directly dispose UI elements without saving
    • This is safe because:
      • Active chats are already saved during normal operation via saveSession()
      • We're just switching views, not losing data
      • Each account's chat history remains properly preserved in storage

Test plan

  1. Create chats in Account A
  2. Switch to Account B and check history to see if the latest chat on Account A is NOT seen on account B
  3. Make a new chat on account B
  4. Switch back to Account A - verify only Account A's chats are visible
  5. Switch to Account B - verify only Account B's chats are visible
  6. Verify logout still properly saves chats before clearing

Changelog

@arafatkatze arafatkatze requested a review from abeatrix December 29, 2024 11:04
@arafatkatze arafatkatze marked this pull request as ready for review December 29, 2024 11:05
@arafatkatze
Copy link
Collaborator Author

@abeatrix So I want to make a test for this but the test for the account switching is flaky? Since this test would be built on top that test so what do i dO?

@abeatrix
Copy link
Contributor

abeatrix commented Dec 29, 2024

@abeatrix So I want to make a test for this but the test for the account switching is flaky? Since this test would be built on top that test so what do i dO?

Oh is it still flaky? I forgot to remove the skip after I updated it since switch account now works.

Update: I marked it as flaky when switch account didn't work

@arafatkatze
Copy link
Collaborator Author

@abeatrix So I changed this to a test that technically works


// TODO: Fix flaky test
test.extend<ExpectedV2Events>({
    expectedV2Events: [
        'cody.extension:installed',
        'cody.auth.login:clicked',
        'cody.auth.login:firstEver',
        'cody.auth.login.token:clicked',
        'cody.auth:connected',
        'cody.userMenu:open',
        'cody.auth:disconnected',
        'cody.interactiveTutorial:attemptingStart',
        'cody.experiment.interactiveTutorial:enrolled',
        'cody.signInNotification:shown',
    ],
})('switch account via account dropdown menu in webview', async ({ page, sidebar }) => {
        await expect(sidebar!.getByText('Sign in to Sourcegraph')).toBeVisible()
        await sidebar!.getByRole('button', { name: 'Sourcegraph logo Continue' }).click()
        await sidebar!.getByText('Sourcegraph Instance URL').click()
        await sidebar!.getByPlaceholder('Example: https://instance.').click()
        await sidebar!.getByPlaceholder('Example: https://instance.').fill(SERVER_URL)

        await sidebar!.getByText('Access Token (Optional)').click()

        await sidebar!.getByPlaceholder('Access token...').click()
        await sidebar!.getByPlaceholder('Access token...').fill(VALID_TOKEN)
        await sidebar!.getByPlaceholder('Access token...').press('Enter')

        await expect(sidebar!.getByText('Invalid access token.')).not.toBeVisible()
        await expect(sidebar!.getByText('Sign in to Sourcegraph')).not.toBeVisible()
        await expect(sidebar!.getByLabel('Chat message')).toBeVisible()

        const sidebarChat = getChatSidebarPanel(page)
        const chatInput = getChatInputs(sidebarChat).first()
        await chatInput.fill('Hello world')
        await chatInput.press('Enter')
        await sidebar!.getByTestId('tab-history').click();
        await sidebar!.getByRole('button', { name: 'Hello world' }).first().click();

        // Open the User Dropdown menu
        await expect(sidebar!.getByRole('button', { name: 'New Chat' })).toBeVisible()
        await sidebar!.getByLabel('Account Menu Button').click({ delay: 2000 })
        await sidebar!.getByRole('option', { name: 'Switch Account' }).click()
        await expect(sidebar!.getByText('Active')).toBeVisible()
        await expect(sidebar!.getByText(SERVER_URL)).toBeVisible()

        await sidebar!.getByText('Add another account').click()
        await expect(sidebar!.getByText('Account Details')).toBeVisible()
        await expect(sidebar!.getByRole('button', { name: 'Add and Switch' })).toBeVisible()
        await sidebar!.getByLabel('Instance URL').click()
        await sidebar!.getByLabel('Instance URL').fill(SERVER_URL)
        await sidebar!.getByText('Access Token (Optional)').click()
        await sidebar!.getByPlaceholder('sgp_xxx_xxx').click()
        await sidebar!.getByPlaceholder('sgp_xxx_xxx').fill(VALID_TOKEN_PERSON2)
        await sidebar!.getByRole('button', { name: 'Add and Switch' }).click()

        // Makes sure the dropdown menu is closed.
        await expect(sidebar!.getByText('Account Details')).not.toBeVisible()
        await expect(sidebar!.getByRole('option', { name: 'Help' })).not.toBeVisible()

        await chatInput.fill('test message')
        await chatInput.press('Enter')
        await sidebar!.getByTestId('tab-history').click();
        expect(await sidebar!.getByRole('button', { name: 'Hello world' }).count()).toBe(0)
    })

This test checks for the condition that we want to test and when I run this with the debugger it runs slowly and gives me the right response


  ✓  4 auth.test.ts:136:3 › switch account via account dropdown menu in webview (37.6s)
Cody output channel: /Users/arafatkhan/Desktop/cody/playwright/switch_account_via_account_dropdown_menu_in_webview/temp-log/logger.log

  Slow test file: auth.test.ts (37.6s)
  Consider splitting slow test files to speed up parallel execution
  3 skipped
  1 passed (38.6s)
➜  cody git:(save-hist-fix) ✗ 

But the part where it switches the accounts happens so fast without the debugger that the frames get detched etc so that's why this is flaky. So we have 3 options

  1. Adding fake timeouts
  2. Doing test.slow() which is what I did now so this raises the timeout but makes the test still work.
  3. Keep the tests but skip them

For now my recommendation is to go with Option 2 and if this ever bothers us again we skip these tests

Copy link
Contributor

@abeatrix abeatrix left a comment

Choose a reason for hiding this comment

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

Left some commands inline that are not merge blocker.

Comment on lines +94 to +102
this.activeEditor = undefined
const oldEditors = this.editors
this.editors = []
for (const editor of oldEditors) {
if (editor.webviewPanelOrView) {
disposeWebviewViewOrPanel(editor.webviewPanelOrView)
}
editor.dispose()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
this.activeEditor = undefined
const oldEditors = this.editors
this.editors = []
for (const editor of oldEditors) {
if (editor.webviewPanelOrView) {
disposeWebviewViewOrPanel(editor.webviewPanelOrView)
}
editor.dispose()
}
this.disposeAllChats()

This looks like it does the same thing as disposeAllChats method? On account switch, the new ChatBuilder(undefined) will be recreated, so it should be safe to saveSession? We should also look into not invoking saveSession when the chat is empty (unrelated to this PR) 👀

@@ -81,11 +81,31 @@ export class ChatsController implements vscode.Disposable {
const hasSwitchedAccount =
this.currentAuthAccount &&
this.currentAuthAccount.endpoint !== authStatus.endpoint
if (hasLoggedOut || hasSwitchedAccount) {
if (hasLoggedOut) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (hasLoggedOut) {
if (hasLoggedOut) {
this.disposeAllChats()
return

Can we return early since the code below is doing the same thing without saving the session?

@@ -1912,10 +1912,11 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv
})
}

public async clearAndRestartSession(chatMessages?: ChatMessage[]): Promise<void> {
public async clearAndRestartSession(chatMessages?: ChatMessage[], shouldSave = true): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Q: Should we separate the secession step from this method if this has been causing issues?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants