Skip to content

Commit df2731f

Browse files
committed
terminal: ignore stale env file refreshes
Make env-file change tests await the refresh directly instead of sleeping, and prevent an older asynchronous refresh from overwriting newer terminal variables.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 192e363 commit df2731f

2 files changed

Lines changed: 73 additions & 19 deletions

File tree

‎src/features/terminal/terminalEnvVarInjector.ts‎

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export class TerminalEnvVarInjector implements Disposable {
2929
private disposables: Disposable[] = [];
3030
// Track which .env variables we've set for each workspace to avoid clearing shell activation variables
3131
private envVarKeys: Map<string, Set<string>> = new Map();
32+
private refreshGenerations: Map<string, number> = new Map();
3233
private envFileNotificationShown = false;
3334

3435
constructor(
@@ -49,19 +50,17 @@ export class TerminalEnvVarInjector implements Disposable {
4950
this.envVarManager.onDidChangeEnvironmentVariables((args) => {
5051
if (!args.uri) {
5152
// No specific URI, reload all workspaces
52-
this.updateEnvironmentVariables().catch((error) => {
53+
return this.updateEnvironmentVariables().catch((error) => {
5354
traceError('Failed to update environment variables:', error);
5455
});
55-
return;
5656
}
5757

5858
const affectedWorkspace = getWorkspaceFolder(args.uri);
5959
if (!affectedWorkspace) {
6060
// No workspace folder found for this URI, reloading all workspaces
61-
this.updateEnvironmentVariables().catch((error) => {
61+
return this.updateEnvironmentVariables().catch((error) => {
6262
traceError('Failed to update environment variables:', error);
6363
});
64-
return;
6564
}
6665

6766
// Check if env file injection is enabled when variables change
@@ -76,7 +75,7 @@ export class TerminalEnvVarInjector implements Disposable {
7675
});
7776
}
7877

79-
this.updateEnvironmentVariables(affectedWorkspace).catch((error) => {
78+
return this.updateEnvironmentVariables(affectedWorkspace).catch((error) => {
8079
traceError('Failed to update environment variables:', error);
8180
});
8281
}),
@@ -105,12 +104,17 @@ export class TerminalEnvVarInjector implements Disposable {
105104
*/
106105
private async updateEnvironmentVariables(workspaceFolder?: WorkspaceFolder): Promise<void> {
107106
try {
107+
let workspaceUpdates: { folder: WorkspaceFolder; generation: number }[];
108108
if (workspaceFolder) {
109-
// Update only the specified workspace
109+
workspaceUpdates = [
110+
{
111+
folder: workspaceFolder,
112+
generation: this.beginWorkspaceRefresh(workspaceFolder),
113+
},
114+
];
110115
traceVerbose(
111116
`TerminalEnvVarInjector: Updating environment variables for workspace: ${workspaceFolder.uri.fsPath}`,
112117
);
113-
await this.injectEnvironmentVariablesForWorkspace(workspaceFolder);
114118
} else {
115119
// No provided workspace - update all workspaces
116120

@@ -121,9 +125,14 @@ export class TerminalEnvVarInjector implements Disposable {
121125
}
122126

123127
traceVerbose('TerminalEnvVarInjector: Updating environment variables for all workspaces');
124-
for (const folder of workspaceFolders) {
125-
await this.injectEnvironmentVariablesForWorkspace(folder);
126-
}
128+
workspaceUpdates = workspaceFolders.map((folder) => ({
129+
folder,
130+
generation: this.beginWorkspaceRefresh(folder),
131+
}));
132+
}
133+
134+
for (const { folder, generation } of workspaceUpdates) {
135+
await this.injectEnvironmentVariablesForWorkspace(folder, generation);
127136
}
128137

129138
traceVerbose('TerminalEnvVarInjector: Environment variable injection completed');
@@ -135,7 +144,10 @@ export class TerminalEnvVarInjector implements Disposable {
135144
/**
136145
* Inject environment variables for a specific workspace.
137146
*/
138-
private async injectEnvironmentVariablesForWorkspace(workspaceFolder: WorkspaceFolder): Promise<void> {
147+
private async injectEnvironmentVariablesForWorkspace(
148+
workspaceFolder: WorkspaceFolder,
149+
generation: number,
150+
): Promise<void> {
139151
const workspaceUri = workspaceFolder.uri;
140152
const workspaceKey = workspaceUri.fsPath;
141153

@@ -154,6 +166,9 @@ export class TerminalEnvVarInjector implements Disposable {
154166
traceVerbose(
155167
`TerminalEnvVarInjector: Env file injection disabled for workspace: ${workspaceUri.fsPath}`,
156168
);
169+
if (!this.isCurrentWorkspaceRefresh(workspaceFolder, generation)) {
170+
return;
171+
}
157172
// Clear only the .env variables we previously set, not shell activation variables
158173
this.clearTrackedEnvVariables(envVarScope, workspaceKey);
159174
return;
@@ -171,6 +186,9 @@ export class TerminalEnvVarInjector implements Disposable {
171186
: (await fse.pathExists(defaultEnvFilePath))
172187
? defaultEnvFilePath
173188
: undefined;
189+
if (!this.isCurrentWorkspaceRefresh(workspaceFolder, generation)) {
190+
return;
191+
}
174192
if (activeEnvFilePath) {
175193
traceVerbose(`TerminalEnvVarInjector: Using env file: ${activeEnvFilePath}`);
176194
} else {
@@ -214,6 +232,17 @@ export class TerminalEnvVarInjector implements Disposable {
214232
}
215233
}
216234

235+
private beginWorkspaceRefresh(workspaceFolder: WorkspaceFolder): number {
236+
const workspaceKey = workspaceFolder.uri.fsPath;
237+
const generation = (this.refreshGenerations.get(workspaceKey) ?? 0) + 1;
238+
this.refreshGenerations.set(workspaceKey, generation);
239+
return generation;
240+
}
241+
242+
private isCurrentWorkspaceRefresh(workspaceFolder: WorkspaceFolder, generation: number): boolean {
243+
return this.refreshGenerations.get(workspaceFolder.uri.fsPath) === generation;
244+
}
245+
217246
/**
218247
* Offer to enable env file injection for the affected workspace folder or suppress future reminders.
219248
*/

‎src/test/features/terminalEnvVarInjector.unit.test.ts‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { ActivationStrings, Common } from '../../common/localize';
2020
import * as logging from '../../common/logging';
2121
import * as persistentState from '../../common/persistentState';
22+
import { createDeferred } from '../../common/utils/deferred';
2223
import * as windowApis from '../../common/window.apis';
2324
import * as workspaceApis from '../../common/workspace.apis';
2425
import { EnvVarManager } from '../../features/execution/envVariableManager';
@@ -153,16 +154,21 @@ suite('TerminalEnvVarInjector', () => {
153154
});
154155

155156
suite('env file changes', () => {
156-
let envChangeCallback: ((args: { uri?: Uri; changeType: number }) => void) | undefined;
157+
let envChangeCallback: ((args: { uri?: Uri; changeType: number }) => Promise<void>) | undefined;
157158
let variables: Record<string, string>;
159+
let variableReads: Promise<Record<string, string>>[];
158160
let existingFiles: Set<string>;
159161
const defaultEnvFile = path.join(testWorkspaceFolder.uri.fsPath, '.env');
160162
const configuredEnvFile = path.join(testWorkspaceFolder.uri.fsPath, 'configured.env');
161163

162164
setup(() => {
163165
variables = {};
166+
variableReads = [];
164167
existingFiles = new Set();
165-
sinon.stub(fse, 'pathExists').callsFake(async (filePath) => existingFiles.has(path.resolve(filePath.toString())));
168+
workspaceFoldersValue = undefined;
169+
sinon
170+
.stub(fse, 'pathExists')
171+
.callsFake(async (filePath) => existingFiles.has(path.resolve(filePath.toString())));
166172
sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(testWorkspaceFolder);
167173
envVarManager.reset();
168174
envVarManager.setup((m) => m.onDidChangeEnvironmentVariables).returns(
@@ -173,13 +179,12 @@ suite('TerminalEnvVarInjector', () => {
173179
);
174180
envVarManager
175181
.setup((m) => m.getEnvironmentVariables(typeMoq.It.isAny()))
176-
.returns(() => Promise.resolve({ ...variables }));
182+
.returns(() => variableReads.shift() ?? Promise.resolve({ ...variables }));
177183
});
178184

179185
async function fireChange(changeType: number, filePath = defaultEnvFile): Promise<void> {
180186
assert.ok(envChangeCallback);
181-
envChangeCallback({ uri: Uri.file(filePath), changeType });
182-
await new Promise((resolve) => setTimeout(resolve, 20));
187+
await envChangeCallback({ uri: Uri.file(filePath), changeType });
183188
}
184189

185190
test('creating an env file with injection disabled preserves shell activation variables', async () => {
@@ -195,7 +200,6 @@ suite('TerminalEnvVarInjector', () => {
195200
test('creating, editing, and deleting an env file updates only its injected variables', async () => {
196201
getConfigurationStub.returns(createMockConfig({ useEnvFile: true }) as WorkspaceConfiguration);
197202
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
198-
await new Promise((resolve) => setTimeout(resolve, 20));
199203

200204
existingFiles.add(defaultEnvFile);
201205
variables = { TERMINAL_PROBE_VALUE: 'created' };
@@ -222,7 +226,7 @@ suite('TerminalEnvVarInjector', () => {
222226
existingFiles.add(configuredEnvFile);
223227
variables = { CONFIGURED_VALUE: 'configured', PROJECT_VALUE: 'project' };
224228
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
225-
await new Promise((resolve) => setTimeout(resolve, 20));
229+
await fireChange(2, configuredEnvFile);
226230

227231
existingFiles.delete(configuredEnvFile);
228232
variables = { PROJECT_VALUE: 'project' };
@@ -241,7 +245,7 @@ suite('TerminalEnvVarInjector', () => {
241245
existingFiles.add(configuredEnvFile);
242246
variables = { CONFIGURED_VALUE: 'configured', PROJECT_VALUE: 'project' };
243247
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
244-
await new Promise((resolve) => setTimeout(resolve, 20));
248+
await fireChange(2);
245249

246250
existingFiles.delete(defaultEnvFile);
247251
variables = { CONFIGURED_VALUE: 'configured' };
@@ -251,6 +255,27 @@ suite('TerminalEnvVarInjector', () => {
251255
sinon.assert.neverCalledWith(mockScopedCollection.delete, 'CONFIGURED_VALUE');
252256
sinon.assert.notCalled(mockScopedCollection.clear);
253257
});
258+
259+
test('does not let an older env file refresh overwrite newer variables', async () => {
260+
getConfigurationStub.returns(createMockConfig({ useEnvFile: true }) as WorkspaceConfiguration);
261+
existingFiles.add(defaultEnvFile);
262+
const olderRefresh = createDeferred<Record<string, string>>();
263+
const newerRefresh = createDeferred<Record<string, string>>();
264+
variableReads.push(olderRefresh.promise, newerRefresh.promise);
265+
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
266+
267+
assert.ok(envChangeCallback);
268+
const firstChange = envChangeCallback({ uri: Uri.file(defaultEnvFile), changeType: 3 });
269+
const secondChange = envChangeCallback({ uri: Uri.file(defaultEnvFile), changeType: 2 });
270+
271+
newerRefresh.resolve({ NEW_VALUE: 'new' });
272+
await secondChange;
273+
olderRefresh.resolve({ OLD_VALUE: 'old' });
274+
await firstChange;
275+
276+
sinon.assert.calledOnceWithExactly(mockScopedCollection.replace, 'NEW_VALUE', 'new');
277+
sinon.assert.neverCalledWith(mockScopedCollection.replace, 'OLD_VALUE', 'old');
278+
});
254279
});
255280

256281
test('should NOT inject when useEnvFile is false even with python.envFile configured', async () => {

0 commit comments

Comments
 (0)