diff --git a/src/core/__tests__/contextProxy.test.ts b/src/core/__tests__/contextProxy.test.ts index e44f3e45b3c..1f654112851 100644 --- a/src/core/__tests__/contextProxy.test.ts +++ b/src/core/__tests__/contextProxy.test.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode" -import { ContextProxy } from "../contextProxy" +import { ContextProxy, WINDOW_SPECIFIC_KEYS } from "../contextProxy" import { logger } from "../../utils/logging" import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../../shared/globalState" @@ -19,6 +19,14 @@ jest.mock("vscode", () => ({ Production: 2, Test: 3, }, + env: { + sessionId: "test-session-id", + machineId: "test-machine-id", + }, + workspace: { + name: "test-workspace-name", + workspaceFolders: [{ uri: { toString: () => "test-workspace" } }], + }, })) describe("ContextProxy", () => { @@ -75,7 +83,14 @@ describe("ContextProxy", () => { it("should initialize state cache with all global state keys", () => { expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length) for (const key of GLOBAL_STATE_KEYS) { - expect(mockGlobalState.get).toHaveBeenCalledWith(key) + if (WINDOW_SPECIFIC_KEYS.includes(key as any)) { + // For window-specific keys like 'mode', the key is prefixed + expect(mockGlobalState.get).toHaveBeenCalledWith( + expect.stringMatching(new RegExp(`^window:.+:${key}$`)), + ) + } else { + expect(mockGlobalState.get).toHaveBeenCalledWith(key) + } } }) @@ -115,9 +130,35 @@ describe("ContextProxy", () => { expect(mockGlobalState.update).toHaveBeenCalledWith("test-key", "new-value") // Should have stored the value in cache - const storedValue = await proxy.getGlobalState("test-key") + const storedValue = proxy.getGlobalState("test-key") expect(storedValue).toBe("new-value") }) + + it("should handle window-specific keys correctly", async () => { + // Test with a window-specific key + await proxy.updateGlobalState("mode", "test-mode") + + // Should have called update with window-specific key + expect(mockGlobalState.update).toHaveBeenCalledWith(expect.stringMatching(/^window:.+:mode$/), "test-mode") + + // Should have stored the value in cache with the original key + const storedValue = proxy.getGlobalState("mode") + expect(storedValue).toBe("test-mode") + }) + + it("should throw and not update cache if storage update fails", async () => { + // Mock a failure in the storage update + mockGlobalState.update.mockRejectedValueOnce(new Error("Storage update failed")) + + // Set initial cache value + proxy["stateCache"].set("error-key", "initial-value") + + // Attempt to update should fail + await expect(proxy.updateGlobalState("error-key", "new-value")).rejects.toThrow("Storage update failed") + + // Cache should still have the initial value + expect(proxy.getGlobalState("error-key")).toBe("initial-value") + }) }) describe("getSecret", () => { @@ -290,7 +331,15 @@ describe("ContextProxy", () => { // Should have called update with undefined for each key for (const key of GLOBAL_STATE_KEYS) { - expect(mockGlobalState.update).toHaveBeenCalledWith(key, undefined) + if (WINDOW_SPECIFIC_KEYS.includes(key as any)) { + // For window-specific keys like 'mode', the key is prefixed + expect(mockGlobalState.update).toHaveBeenCalledWith( + expect.stringMatching(new RegExp(`^window:.+:${key}$`)), + undefined, + ) + } else { + expect(mockGlobalState.update).toHaveBeenCalledWith(key, undefined) + } } // Total calls should include initial setup + reset operations @@ -328,4 +377,114 @@ describe("ContextProxy", () => { expect(initSecretCache).toHaveBeenCalledTimes(1) }) }) + + describe("Window-specific state", () => { + it("should use window-specific key for mode", async () => { + // Ensure 'mode' is in window specific keys + expect(WINDOW_SPECIFIC_KEYS).toContain("mode") + + // Test update method with 'mode' key + await proxy.updateGlobalState("mode", "debug") + + // Verify it's called with window-specific key + expect(mockGlobalState.update).toHaveBeenCalledWith(expect.stringMatching(/^window:.+:mode$/), "debug") + }) + + it("should use regular key for non-window-specific state", async () => { + // Test update method with a regular key + await proxy.updateGlobalState("apiProvider", "test-provider") + + // Verify it's called with regular key + expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "test-provider") + }) + + it("should consistently use same key format for get/update operations", async () => { + // Set mock values for testing + const windowKeyPattern = /^window:.+:mode$/ + mockGlobalState.get.mockImplementation((key: string) => { + if (windowKeyPattern.test(key)) return "window-debug-mode" + if (key === "mode") return "global-debug-mode" + return undefined + }) + + // Update a window-specific value + await proxy.updateGlobalState("mode", "test-mode") + + // The key used in update should match pattern + const updateCallArg = mockGlobalState.update.mock.calls[0][0] + expect(updateCallArg).toMatch(windowKeyPattern) + + // Re-init to load values + proxy["initializeStateCache"]() + + // Verify we get the window-specific value back + const value = proxy.getGlobalState("mode") + + // We should get the window-specific value, not the global one + expect(mockGlobalState.get).toHaveBeenCalledWith(expect.stringMatching(windowKeyPattern)) + expect(value).not.toBe("global-debug-mode") + }) + }) + + describe("Enhanced window ID generation", () => { + it("should generate a window ID that includes workspace name", () => { + // Access the private method using type assertion + const generateWindowId = (proxy as any).generateWindowId.bind(proxy) + const windowId = generateWindowId() + + // Should include the workspace name from our mock + expect(windowId).toContain("test-workspace-name") + }) + + it("should generate a window ID that includes machine ID", () => { + // Access the private method using type assertion + const generateWindowId = (proxy as any).generateWindowId.bind(proxy) + const windowId = generateWindowId() + + // Should include the machine ID from our mock + expect(windowId).toContain("test-machine-id") + }) + + it("should use the fallback mechanism if generateWindowId fails", () => { + // Create a proxy instance with a failing generateWindowId method + const spyOnGenerate = jest + .spyOn(ContextProxy.prototype as any, "generateWindowId") + .mockImplementation(() => "") + + // Create a new proxy to trigger the constructor with our mock + const testProxy = new ContextProxy(mockContext) + + // Should have called ensureUniqueWindowId with a fallback + expect(spyOnGenerate).toHaveBeenCalled() + + // The windowId should use the fallback format (random ID) + // We can't test the exact value, but we can verify it's not empty + expect((testProxy as any).windowId).not.toBe("") + + // Restore original implementation + spyOnGenerate.mockRestore() + }) + + it("should create consistent session hash for same input", () => { + // Access the private method using type assertion + const createSessionHash = (proxy as any).createSessionHash.bind(proxy) + + const hash1 = createSessionHash("test-input") + const hash2 = createSessionHash("test-input") + + // Same input should produce same hash within the same session + expect(hash1).toBe(hash2) + }) + + it("should create different session hashes for different inputs", () => { + // Access the private method using type assertion + const createSessionHash = (proxy as any).createSessionHash.bind(proxy) + + const hash1 = createSessionHash("test-input-1") + const hash2 = createSessionHash("test-input-2") + + // Different inputs should produce different hashes + expect(hash1).not.toBe(hash2) + }) + }) }) diff --git a/src/core/contextProxy.ts b/src/core/contextProxy.ts index 52197d99f3a..2e83895cfc7 100644 --- a/src/core/contextProxy.ts +++ b/src/core/contextProxy.ts @@ -2,10 +2,16 @@ import * as vscode from "vscode" import { logger } from "../utils/logging" import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../shared/globalState" +// Keys that should be stored per-window rather than globally +export const WINDOW_SPECIFIC_KEYS = ["mode"] as const +export type WindowSpecificKey = (typeof WINDOW_SPECIFIC_KEYS)[number] + export class ContextProxy { private readonly originalContext: vscode.ExtensionContext private stateCache: Map private secretCache: Map + private windowId: string + private readonly instanceCreationTime: Date = new Date() constructor(context: vscode.ExtensionContext) { // Initialize properties first @@ -13,6 +19,10 @@ export class ContextProxy { this.stateCache = new Map() this.secretCache = new Map() + // Generate a unique ID for this window instance + this.windowId = this.ensureUniqueWindowId() + logger.debug(`ContextProxy created with windowId: ${this.windowId}`) + // Initialize state cache with all defined global state keys this.initializeStateCache() @@ -22,12 +32,134 @@ export class ContextProxy { logger.debug("ContextProxy created") } + /** + * Ensures we have a unique window ID, with fallback mechanisms if primary generation fails + * @returns A string ID unique to this VS Code window + */ + private ensureUniqueWindowId(): string { + // Try to get a stable ID first + let id = this.generateWindowId() + + // If all else fails, use a purely random ID as ultimate fallback + // This will not be stable across restarts but ensures uniqueness + if (!id) { + id = `random_${Date.now()}_${Math.random().toString(36).substring(2, 9)}` + logger.warn("Failed to generate stable window ID, using random ID instead") + } + + return id + } + + /** + * Generates a unique identifier for the current VS Code window + * This is used to namespace certain global state values to prevent + * conflicts when using multiple VS Code windows. + * + * The ID generation uses multiple sources to ensure uniqueness even in + * environments where workspace folders might be identical (like DevContainers). + * + * @returns A string ID unique to this VS Code window + */ + private generateWindowId(): string { + try { + // Get all available identifying information + const folders = vscode.workspace.workspaceFolders || [] + const workspaceName = vscode.workspace.name || "unknown" + const folderPaths = folders.map((folder) => folder.uri.toString()).join("|") + + // Generate a stable, pseudorandom ID based on the workspace information + // This will be consistent for the same workspace but different across workspaces + const baseId = `${workspaceName}|${folderPaths}` + + // Add machine-specific information (will differ between host and containers) + // env.machineId is stable across VS Code sessions on the same machine + const machineSpecificId = vscode.env.machineId || "" + + // Add a session component that distinguishes multiple windows with the same workspace + // Creates a stable but reasonably unique hash + const sessionHash = this.createSessionHash(baseId) + + // Combine all components + return `${baseId}|${machineSpecificId}|${sessionHash}` + } catch (error) { + logger.error("Error generating window ID:", error) + return "" // Empty string triggers the fallback in ensureUniqueWindowId + } + } + + /** + * Creates a stable hash from input string and window-specific properties + * that will be different for different VS Code windows even with identical projects + */ + private createSessionHash(input: string): string { + try { + // Use a combination of: + // 1. The extension instance creation time + const timestamp = this.instanceCreationTime.getTime() + + // 2. VS Code window-specific info we can derive + // Using vscode.env.sessionId which changes on each VS Code window startup + const sessionInfo = vscode.env.sessionId || "" + + // 3. Calculate a simple hash + const hashStr = `${input}|${sessionInfo}|${timestamp}` + let hash = 0 + for (let i = 0; i < hashStr.length; i++) { + const char = hashStr.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32bit integer + } + + // Return a hexadecimal representation + return Math.abs(hash).toString(16).substring(0, 8) + } catch (error) { + logger.error("Error creating session hash:", error) + return Math.random().toString(36).substring(2, 10) // Random fallback + } + } + + /** + * Checks if a key should be stored per-window + * @param key The key to check + * @returns True if the key should be stored per-window, false otherwise + */ + private isWindowSpecificKey(key: string): boolean { + return WINDOW_SPECIFIC_KEYS.includes(key as WindowSpecificKey) + } + + /** + * Converts a regular key to a window-specific key + * @param key The original key + * @returns The window-specific key with window ID prefix + */ + private getWindowSpecificKey(key: string): string { + return `window:${this.windowId}:${key}` + } + // Helper method to initialize state cache private initializeStateCache(): void { for (const key of GLOBAL_STATE_KEYS) { try { - const value = this.originalContext.globalState.get(key) - this.stateCache.set(key, value) + if (this.isWindowSpecificKey(key)) { + // For window-specific keys, first try to get the value using the window-specific key + const windowKey = this.getWindowSpecificKey(key) + let value = this.originalContext.globalState.get(windowKey) + + // If no window-specific value exists, try to get a global value as fallback + if (value === undefined) { + value = this.originalContext.globalState.get(key) + logger.debug( + `No window-specific value found for ${windowKey}, using global value for ${key}: ${value}`, + ) + } + + this.stateCache.set(key, value) + logger.debug(`Loaded window-specific key ${key} as ${windowKey} with value: ${value}`) + } else { + // For global keys, use the regular key + const value = this.originalContext.globalState.get(key) + this.stateCache.set(key, value) + } } catch (error) { logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`) } @@ -70,13 +202,34 @@ export class ContextProxy { getGlobalState(key: string): T | undefined getGlobalState(key: string, defaultValue: T): T getGlobalState(key: string, defaultValue?: T): T | undefined { + // The cache already contains the correct value regardless of whether + // this is a window-specific key (handled during initialization and updates) const value = this.stateCache.get(key) as T | undefined return value !== undefined ? value : (defaultValue as T | undefined) } - updateGlobalState(key: string, value: T): Thenable { - this.stateCache.set(key, value) - return this.originalContext.globalState.update(key, value) + async updateGlobalState(key: string, value: T): Promise { + try { + // Determine the storage key + const storageKey = this.isWindowSpecificKey(key) ? this.getWindowSpecificKey(key) : key + + if (this.isWindowSpecificKey(key)) { + logger.debug( + `Updating window-specific key ${key} as ${storageKey} with value: ${JSON.stringify(value)}`, + ) + } + + // Update in VSCode storage first + await this.originalContext.globalState.update(storageKey, value) + + // Only update cache if storage update succeeded + this.stateCache.set(key, value) + } catch (error) { + logger.error( + `Failed to update global state for key ${key}: ${error instanceof Error ? error.message : String(error)}`, + ) + throw error // Re-throw to allow callers to handle the error + } } getSecret(key: string): string | undefined { @@ -140,16 +293,27 @@ export class ContextProxy { this.stateCache.clear() this.secretCache.clear() + // Create an array for all reset promises + const resetPromises: Thenable[] = [] + // Reset all global state values to undefined - const stateResetPromises = GLOBAL_STATE_KEYS.map((key) => - this.originalContext.globalState.update(key, undefined), - ) + for (const key of GLOBAL_STATE_KEYS) { + if (this.isWindowSpecificKey(key)) { + // For window-specific keys, reset using the window-specific key + const windowKey = this.getWindowSpecificKey(key) + resetPromises.push(this.originalContext.globalState.update(windowKey, undefined)) + } else { + resetPromises.push(this.originalContext.globalState.update(key, undefined)) + } + } // Delete all secrets - const secretResetPromises = SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)) + for (const key of SECRET_KEYS) { + resetPromises.push(this.originalContext.secrets.delete(key)) + } // Wait for all reset operations to complete - await Promise.all([...stateResetPromises, ...secretResetPromises]) + await Promise.all(resetPromises) this.initializeStateCache() this.initializeSecretCache()