diff --git a/package-lock.json b/package-lock.json index 1ac3bcbf..d3e775a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1296,7 +1296,6 @@ "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -2150,7 +2149,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2476,7 +2474,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3357,7 +3354,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6135,7 +6131,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7003,7 +6998,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7163,7 +7157,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -7212,7 +7205,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -8436,7 +8428,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.16.0.tgz", "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, - "peer": true, "requires": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -9025,8 +9016,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "peer": true + "dev": true }, "acorn-import-phases": { "version": "1.0.4", @@ -9249,7 +9239,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, - "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9854,7 +9843,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, - "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11828,7 +11816,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12431,8 +12418,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, - "peer": true + "dev": true }, "uc.micro": { "version": "2.1.0", @@ -12540,7 +12526,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, - "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -12574,7 +12559,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/src/managers/builtin/cache.ts b/src/managers/builtin/cache.ts index f6bf5ba0..16c4192a 100644 --- a/src/managers/builtin/cache.ts +++ b/src/managers/builtin/cache.ts @@ -6,7 +6,11 @@ export const SYSTEM_GLOBAL_KEY = `${ENVS_EXTENSION_ID}:system:GLOBAL_SELECTED`; export async function clearSystemEnvCache(): Promise { const workspaceState = await getWorkspacePersistentState(); - await workspaceState.clear([SYSTEM_WORKSPACE_KEY]); + // The global system-Python selection is mirrored into workspaceState (see + // getSystemEnvForGlobal/setSystemEnvForGlobal) so that the cross-session + // cache survives on fresh remotes where globalState is cold. Clear both + // the workspace-scoped map and the mirrored global key here. + await workspaceState.clear([SYSTEM_WORKSPACE_KEY, SYSTEM_GLOBAL_KEY]); const globalState = await getGlobalPersistentState(); await globalState.clear([SYSTEM_GLOBAL_KEY]); } @@ -48,12 +52,43 @@ export async function setSystemEnvForWorkspaces(fsPath: string[], envPath: strin await state.set(SYSTEM_WORKSPACE_KEY, data); } +/** + * Returns the cached path of the discovered global system Python. + * + * Reads from `workspaceState` first and falls back to `globalState`. The + * workspace layer is the primary cache because, in remote scenarios + * (SSH / WSL / dev containers / codespaces), `context.globalState` is scoped + * to the remote machine and starts empty on every fresh remote. The + * workspaceState mirror survives across sessions of the same workspace + * folder on the same remote, so the foreground env-selection fast path can + * hit the cache without waiting on a full PET refresh. See PR #1455 and + * the cross-session cache acceptance criteria. + */ export async function getSystemEnvForGlobal(): Promise { - const state = await getGlobalPersistentState(); - return await state.get(SYSTEM_GLOBAL_KEY); + const workspaceState = await getWorkspacePersistentState(); + const workspaceValue = await workspaceState.get(SYSTEM_GLOBAL_KEY); + if (workspaceValue) { + return workspaceValue; + } + const globalState = await getGlobalPersistentState(); + return await globalState.get(SYSTEM_GLOBAL_KEY); } +/** + * Persists the cached path of the discovered global system Python. + * + * Writes to BOTH `workspaceState` and `globalState` so the cache survives a + * cold `globalState` on remotes (see `getSystemEnvForGlobal` for context). + * Passing `undefined` invalidates both layers, keeping stale-path cleanup + * consistent. + */ export async function setSystemEnvForGlobal(envPath: string | undefined): Promise { - const state = await getGlobalPersistentState(); - await state.set(SYSTEM_GLOBAL_KEY, envPath); + const [workspaceState, globalState] = await Promise.all([ + getWorkspacePersistentState(), + getGlobalPersistentState(), + ]); + await Promise.all([ + workspaceState.set(SYSTEM_GLOBAL_KEY, envPath), + globalState.set(SYSTEM_GLOBAL_KEY, envPath), + ]); } diff --git a/src/test/managers/builtin/cache.systemEnvGlobal.unit.test.ts b/src/test/managers/builtin/cache.systemEnvGlobal.unit.test.ts new file mode 100644 index 00000000..a84721ea --- /dev/null +++ b/src/test/managers/builtin/cache.systemEnvGlobal.unit.test.ts @@ -0,0 +1,195 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import * as persistentState from '../../../common/persistentState'; +import { + clearSystemEnvCache, + getSystemEnvForGlobal, + setSystemEnvForGlobal, + SYSTEM_GLOBAL_KEY, + SYSTEM_WORKSPACE_KEY, +} from '../../../managers/builtin/cache'; + +suite('builtin cache - system global env two-tier lookup', () => { + let workspaceMock: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub }; + let globalMock: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub }; + let getWorkspaceStub: sinon.SinonStub; + let getGlobalStub: sinon.SinonStub; + + setup(() => { + workspaceMock = { + get: sinon.stub(), + set: sinon.stub().resolves(), + clear: sinon.stub().resolves(), + }; + globalMock = { + get: sinon.stub(), + set: sinon.stub().resolves(), + clear: sinon.stub().resolves(), + }; + getWorkspaceStub = sinon + .stub(persistentState, 'getWorkspacePersistentState') + .resolves(workspaceMock as unknown as persistentState.PersistentState); + getGlobalStub = sinon + .stub(persistentState, 'getGlobalPersistentState') + .resolves(globalMock as unknown as persistentState.PersistentState); + }); + + teardown(() => { + sinon.restore(); + }); + + suite('getSystemEnvForGlobal', () => { + test('returns workspaceState value when present (primary layer)', async () => { + workspaceMock.get.withArgs(SYSTEM_GLOBAL_KEY).resolves('/ws/python'); + globalMock.get.withArgs(SYSTEM_GLOBAL_KEY).resolves('/global/python'); + + const result = await getSystemEnvForGlobal(); + + assert.strictEqual(result, '/ws/python', 'workspaceState should take priority'); + assert.ok(workspaceMock.get.calledWith(SYSTEM_GLOBAL_KEY), 'should query workspaceState'); + assert.ok(globalMock.get.notCalled, 'should not query globalState when workspace has a value'); + }); + + test('falls back to globalState when workspaceState is empty (cold workspace mirror)', async () => { + workspaceMock.get.withArgs(SYSTEM_GLOBAL_KEY).resolves(undefined); + globalMock.get.withArgs(SYSTEM_GLOBAL_KEY).resolves('/global/python'); + + const result = await getSystemEnvForGlobal(); + + assert.strictEqual(result, '/global/python', 'should fall back to globalState'); + assert.ok(workspaceMock.get.calledWith(SYSTEM_GLOBAL_KEY), 'should query workspaceState first'); + assert.ok(globalMock.get.calledWith(SYSTEM_GLOBAL_KEY), 'should then query globalState'); + }); + + test('returns undefined when both layers are empty (true cold cache)', async () => { + workspaceMock.get.withArgs(SYSTEM_GLOBAL_KEY).resolves(undefined); + globalMock.get.withArgs(SYSTEM_GLOBAL_KEY).resolves(undefined); + + const result = await getSystemEnvForGlobal(); + + assert.strictEqual(result, undefined, 'should return undefined when nothing cached'); + }); + + test('falls back to globalState when workspaceState value is empty string', async () => { + // An empty string is falsy and should not count as a cached selection. + workspaceMock.get.withArgs(SYSTEM_GLOBAL_KEY).resolves(''); + globalMock.get.withArgs(SYSTEM_GLOBAL_KEY).resolves('/global/python'); + + const result = await getSystemEnvForGlobal(); + + assert.strictEqual(result, '/global/python'); + }); + + test('does not interact with persistent state when called (sanity: lazy/awaited only)', async () => { + // Smoke check that the deferred-resolved helpers are awaited. + workspaceMock.get.resolves('/ws/python'); + await getSystemEnvForGlobal(); + assert.ok(getWorkspaceStub.called, 'workspace persistent state accessor invoked'); + }); + }); + + suite('setSystemEnvForGlobal', () => { + test('writes the same value to BOTH workspaceState and globalState', async () => { + await setSystemEnvForGlobal('/some/python'); + + assert.ok( + workspaceMock.set.calledWith(SYSTEM_GLOBAL_KEY, '/some/python'), + 'workspaceState should receive the mirrored write', + ); + assert.ok( + globalMock.set.calledWith(SYSTEM_GLOBAL_KEY, '/some/python'), + 'globalState should receive the canonical write', + ); + assert.ok(getWorkspaceStub.called && getGlobalStub.called, 'both persistent state accessors invoked'); + }); + + test('invalidates BOTH layers when called with undefined (stale-path invalidation)', async () => { + await setSystemEnvForGlobal(undefined); + + assert.ok( + workspaceMock.set.calledWith(SYSTEM_GLOBAL_KEY, undefined), + 'workspaceState mirror should be cleared', + ); + assert.ok( + globalMock.set.calledWith(SYSTEM_GLOBAL_KEY, undefined), + 'globalState should be cleared', + ); + }); + + test('round-trip: after set, get returns the value from workspaceState (primary)', async () => { + // Simulate persistent state by wiring set to update get's resolved value. + let workspaceStored: string | undefined; + let globalStored: string | undefined; + workspaceMock.set.callsFake(async (_key: string, value: string | undefined) => { + workspaceStored = value; + }); + globalMock.set.callsFake(async (_key: string, value: string | undefined) => { + globalStored = value; + }); + workspaceMock.get.withArgs(SYSTEM_GLOBAL_KEY).callsFake(async () => workspaceStored); + globalMock.get.withArgs(SYSTEM_GLOBAL_KEY).callsFake(async () => globalStored); + + await setSystemEnvForGlobal('/round/trip/python'); + const result = await getSystemEnvForGlobal(); + + assert.strictEqual(result, '/round/trip/python'); + assert.strictEqual(workspaceStored, '/round/trip/python'); + assert.strictEqual(globalStored, '/round/trip/python'); + }); + }); + + suite('clearSystemEnvCache', () => { + test('clears workspace-scoped key, workspace-mirrored global key, and globalState global key', async () => { + await clearSystemEnvCache(); + + assert.ok(workspaceMock.clear.calledOnce, 'workspaceState.clear should be called once'); + const workspaceClearedKeys = workspaceMock.clear.firstCall.args[0] as string[]; + assert.deepStrictEqual( + [...workspaceClearedKeys].sort(), + [SYSTEM_GLOBAL_KEY, SYSTEM_WORKSPACE_KEY].sort(), + 'workspaceState.clear should clear both the workspace map and the mirrored global key', + ); + + assert.ok(globalMock.clear.calledOnce, 'globalState.clear should be called once'); + assert.deepStrictEqual( + globalMock.clear.firstCall.args[0], + [SYSTEM_GLOBAL_KEY], + 'globalState.clear should clear the global key', + ); + }); + + test('after clear, get returns undefined (full invalidation through both layers)', async () => { + // Simulate the in-memory backing for both layers. + const workspaceBacking: { [key: string]: unknown } = { + [SYSTEM_GLOBAL_KEY]: '/cached/ws/python', + [SYSTEM_WORKSPACE_KEY]: { '/proj': '/cached/proj/python' }, + }; + const globalBacking: { [key: string]: unknown } = { + [SYSTEM_GLOBAL_KEY]: '/cached/global/python', + }; + workspaceMock.get.callsFake(async (key: string) => workspaceBacking[key]); + globalMock.get.callsFake(async (key: string) => globalBacking[key]); + workspaceMock.clear.callsFake(async (keys: string[]) => { + for (const k of keys) { + delete workspaceBacking[k]; + } + }); + globalMock.clear.callsFake(async (keys: string[]) => { + for (const k of keys) { + delete globalBacking[k]; + } + }); + + // Pre-condition: get sees the workspace-mirrored value. + assert.strictEqual(await getSystemEnvForGlobal(), '/cached/ws/python'); + + await clearSystemEnvCache(); + + assert.strictEqual( + await getSystemEnvForGlobal(), + undefined, + 'both layers should be cleared, so global lookup is empty', + ); + }); + }); +});