Skip to content

Commit 9df1acb

Browse files
authored
fix: avoid spurious 'restart terminal' prompts on every window reload (#1648)
* fix: avoid spurious 'restart terminal' prompts on window reload VS Code shows a 'restart terminal to apply environment variable changes' prompt whenever an extension's EnvironmentVariableCollection differs from what is already applied to running terminals. The previous No-Config Debug activation flow called collection.clear() followed by replace() / append() unconditionally, which VS Code always interprets as a change even when the resulting values are byte-identical, so the prompt fired on every window reload. Refactor the activation to diff-aware helpers (applyReplaceIfChanged, applyAppendIfChanged, deleteIfPresent) that only write to the collection when the existing mutator's type, value, or options actually differ from what we want. The hot path on a normal reload becomes a sequence of no-op get() comparisons, so VS Code never observes a change and the prompt no longer appears. Additional notes: * Compare normalized EnvironmentVariableMutatorOptions (applyAtProcessCreation / applyAtShellIntegration) so future options changes still trigger a write. * When the Java Language Server cannot resolve a Java home, keep the previously stored VSCODE_JAVA_EXEC instead of deleting it, to avoid churn caused by transient startup failures. * Explicitly clean up legacy keys (currently JAVA_TOOL_OPTIONS) that older versions of this extension used to set but no longer manage. * Set a human-readable description on the collection so users can see the source in VS Code's environment variable UI. Fixes #1647 * fix: drop unused legacy env var cleanup per review feedback JAVA_TOOL_OPTIONS has never been written to the per-extension EnvironmentVariableCollection by this codebase (verified via 'git log -S JAVA_TOOL_OPTIONS'). The legacy cleanup loop was therefore dead code, and could be misread as touching user-managed env vars (it cannot — the collection is per-extension and only sees mutators this extension wrote). * refactor: drop unused deleteIfPresent helper Production code no longer calls deleteIfPresent after the legacy cleanup loop was removed. Drop the helper and its tests rather than carry a YAGNI export — easy to reintroduce if a real caller appears.
1 parent 9758e1d commit 9df1acb

3 files changed

Lines changed: 328 additions & 8 deletions

File tree

src/envVarSync.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
4+
import * as vscode from "vscode";
5+
6+
/**
7+
* Helpers that update a {@link vscode.EnvironmentVariableCollection} only when
8+
* the resulting mutation would actually change the collection.
9+
*
10+
* Background: VS Code shows a "Restart terminal to apply environment variable
11+
* changes" prompt whenever an extension's collection differs from what has
12+
* already been applied to running terminals. Calling `replace()` / `append()`
13+
* with the same value, or calling `clear()` followed by re-adding identical
14+
* entries, still counts as a change and re-triggers the prompt on every
15+
* window reload. See issue #1647.
16+
*
17+
* These helpers compare the existing mutator (type + value + options) against
18+
* the desired one and skip the write entirely when they match.
19+
*/
20+
21+
const DEFAULT_OPTIONS: Required<vscode.EnvironmentVariableMutatorOptions> = {
22+
applyAtProcessCreation: true,
23+
applyAtShellIntegration: false,
24+
};
25+
26+
function normalizeOptions(
27+
options?: vscode.EnvironmentVariableMutatorOptions,
28+
): Required<vscode.EnvironmentVariableMutatorOptions> {
29+
return { ...DEFAULT_OPTIONS, ...options };
30+
}
31+
32+
function sameOptions(
33+
existing: vscode.EnvironmentVariableMutator,
34+
desired?: vscode.EnvironmentVariableMutatorOptions,
35+
): boolean {
36+
const e = normalizeOptions(existing.options);
37+
const d = normalizeOptions(desired);
38+
return e.applyAtProcessCreation === d.applyAtProcessCreation
39+
&& e.applyAtShellIntegration === d.applyAtShellIntegration;
40+
}
41+
42+
/**
43+
* Calls `collection.replace(variable, value, options)` only when the existing
44+
* mutator (if any) does not already match.
45+
*
46+
* @returns `true` if the collection was actually written to.
47+
*/
48+
export function applyReplaceIfChanged(
49+
collection: vscode.EnvironmentVariableCollection,
50+
variable: string,
51+
value: string,
52+
options?: vscode.EnvironmentVariableMutatorOptions,
53+
): boolean {
54+
const existing = collection.get(variable);
55+
if (existing
56+
&& existing.type === vscode.EnvironmentVariableMutatorType.Replace
57+
&& existing.value === value
58+
&& sameOptions(existing, options)) {
59+
return false;
60+
}
61+
if (options) {
62+
collection.replace(variable, value, options);
63+
} else {
64+
collection.replace(variable, value);
65+
}
66+
return true;
67+
}
68+
69+
/**
70+
* Calls `collection.append(variable, value, options)` only when the existing
71+
* mutator (if any) does not already match.
72+
*
73+
* @returns `true` if the collection was actually written to.
74+
*/
75+
export function applyAppendIfChanged(
76+
collection: vscode.EnvironmentVariableCollection,
77+
variable: string,
78+
value: string,
79+
options?: vscode.EnvironmentVariableMutatorOptions,
80+
): boolean {
81+
const existing = collection.get(variable);
82+
if (existing
83+
&& existing.type === vscode.EnvironmentVariableMutatorType.Append
84+
&& existing.value === value
85+
&& sameOptions(existing, options)) {
86+
return false;
87+
}
88+
if (options) {
89+
collection.append(variable, value, options);
90+
} else {
91+
collection.append(variable, value);
92+
}
93+
return true;
94+
}
95+

src/noConfigDebugInit.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import * as vscode from 'vscode';
99
import { sendInfo, sendError } from "vscode-extension-telemetry-wrapper";
1010
import { getJavaHome } from "./utility";
1111
import { buildNoConfigPathAppendValue } from "./pathUtil";
12+
import { applyAppendIfChanged, applyReplaceIfChanged } from "./envVarSync";
13+
14+
const ENV_VAR_COLLECTION_DESCRIPTION = "Java No-Config Debug";
1215

1316
/**
1417
* Registers the configuration-less debugging setup for the extension.
@@ -21,7 +24,7 @@ import { buildNoConfigPathAppendValue } from "./pathUtil";
2124
*
2225
* Environment Variables:
2326
* - `VSCODE_JDWP_ADAPTER_ENDPOINTS`: Path to the file containing the debugger adapter endpoint.
24-
* - `JAVA_TOOL_OPTIONS`: JDWP configuration for automatic debugging.
27+
* - `VSCODE_JAVA_EXEC`: Path to the java executable from the Java Language Server (when available).
2528
* - `PATH`: Appends the path to the noConfigScripts directory.
2629
*/
2730
export async function registerNoConfigDebug(
@@ -69,30 +72,39 @@ export async function registerNoConfigDebug(
6972
}
7073
}
7174

72-
// clear the env var collection to remove any existing env vars
73-
collection.clear();
75+
// Surface a description in VS Code's environment variable UI so users can
76+
// see which extension is contributing these variables.
77+
if (collection.description !== ENV_VAR_COLLECTION_DESCRIPTION) {
78+
collection.description = ENV_VAR_COLLECTION_DESCRIPTION;
79+
}
7480

75-
// Add env var for VSCODE_JDWP_ADAPTER_ENDPOINTS
81+
// Apply our managed variables using diff-aware helpers. On a typical
82+
// window reload the values are unchanged and these calls are no-ops, so
83+
// VS Code does not prompt the user to restart their existing terminals.
84+
// See issue #1647.
85+
//
7686
// Note: We do NOT set JAVA_TOOL_OPTIONS globally to avoid affecting all Java processes
7787
// (javac, maven, gradle, language server, etc.). Instead, JAVA_TOOL_OPTIONS is set
7888
// only in the debugjava wrapper scripts (debugjava.ps1, debugjava.bat, debugjava)
79-
collection.replace('VSCODE_JDWP_ADAPTER_ENDPOINTS', tempFilePath);
89+
applyReplaceIfChanged(collection, 'VSCODE_JDWP_ADAPTER_ENDPOINTS', tempFilePath);
8090

8191
// Try to get Java executable from Java Language Server
82-
// This ensures we use the same Java version as the project is compiled with
92+
// This ensures we use the same Java version as the project is compiled with.
93+
// If detection fails or returns nothing, we deliberately keep any previously
94+
// set VSCODE_JAVA_EXEC to avoid churn from transient startup failures.
8395
try {
8496
const javaHome = await getJavaHome();
8597
if (javaHome) {
8698
const javaExec = path.join(javaHome, 'bin', 'java');
87-
collection.replace('VSCODE_JAVA_EXEC', javaExec);
99+
applyReplaceIfChanged(collection, 'VSCODE_JAVA_EXEC', javaExec);
88100
}
89101
} catch (error) {
90102
// If we can't get Java from Language Server, that's okay
91103
// The wrapper script will fall back to JAVA_HOME or PATH
92104
}
93105

94106
const noConfigScriptsDir = path.join(extPath, 'bundled', 'scripts', 'noConfigScripts');
95-
collection.append('PATH', buildNoConfigPathAppendValue(noConfigScriptsDir));
107+
applyAppendIfChanged(collection, 'PATH', buildNoConfigPathAppendValue(noConfigScriptsDir));
96108

97109
// create file system watcher for the debuggerAdapterEndpointFolder for when the communication port is written
98110
const fileSystemWatcher = vscode.workspace.createFileSystemWatcher(

test/envVarSync.test.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
4+
import * as assert from "assert";
5+
import * as vscode from "vscode";
6+
7+
import {
8+
applyAppendIfChanged,
9+
applyReplaceIfChanged,
10+
} from "../src/envVarSync";
11+
12+
interface FakeMutator {
13+
type: vscode.EnvironmentVariableMutatorType;
14+
value: string;
15+
options: vscode.EnvironmentVariableMutatorOptions;
16+
}
17+
18+
interface FakeCollection extends vscode.EnvironmentVariableCollection {
19+
__calls: { replace: number; append: number; delete: number };
20+
}
21+
22+
function createFakeCollection(): FakeCollection {
23+
const store = new Map<string, FakeMutator>();
24+
const calls = { replace: 0, append: 0, delete: 0 };
25+
26+
const collection = {
27+
persistent: true,
28+
description: undefined as string | vscode.MarkdownString | undefined,
29+
get(name: string): FakeMutator | undefined {
30+
return store.get(name);
31+
},
32+
replace(name: string, value: string, options?: vscode.EnvironmentVariableMutatorOptions): void {
33+
calls.replace += 1;
34+
store.set(name, {
35+
type: vscode.EnvironmentVariableMutatorType.Replace,
36+
value,
37+
options: { applyAtProcessCreation: true, applyAtShellIntegration: false, ...options },
38+
});
39+
},
40+
append(name: string, value: string, options?: vscode.EnvironmentVariableMutatorOptions): void {
41+
calls.append += 1;
42+
store.set(name, {
43+
type: vscode.EnvironmentVariableMutatorType.Append,
44+
value,
45+
options: { applyAtProcessCreation: true, applyAtShellIntegration: false, ...options },
46+
});
47+
},
48+
prepend(name: string, value: string, options?: vscode.EnvironmentVariableMutatorOptions): void {
49+
store.set(name, {
50+
type: vscode.EnvironmentVariableMutatorType.Prepend,
51+
value,
52+
options: { applyAtProcessCreation: true, applyAtShellIntegration: false, ...options },
53+
});
54+
},
55+
delete(name: string): void {
56+
calls.delete += 1;
57+
store.delete(name);
58+
},
59+
clear(): void {
60+
store.clear();
61+
},
62+
forEach(callback: (variable: string, mutator: FakeMutator, collection: any) => void): void {
63+
store.forEach((mutator, variable) => callback(variable, mutator, collection));
64+
},
65+
getScoped(): vscode.EnvironmentVariableCollection {
66+
return collection as unknown as vscode.EnvironmentVariableCollection;
67+
},
68+
*[Symbol.iterator](): IterableIterator<[string, FakeMutator]> {
69+
yield* store.entries();
70+
},
71+
__calls: calls,
72+
};
73+
74+
return collection as unknown as FakeCollection;
75+
}
76+
77+
suite("envVarSync", () => {
78+
suite("applyReplaceIfChanged", () => {
79+
test("writes when variable is missing", () => {
80+
const c = createFakeCollection();
81+
const changed = applyReplaceIfChanged(c, "FOO", "bar");
82+
assert.strictEqual(changed, true);
83+
assert.strictEqual(c.__calls.replace, 1);
84+
assert.strictEqual(c.get("FOO")!.value, "bar");
85+
assert.strictEqual(c.get("FOO")!.type, vscode.EnvironmentVariableMutatorType.Replace);
86+
});
87+
88+
test("is a no-op when value and type already match (default options)", () => {
89+
const c = createFakeCollection();
90+
applyReplaceIfChanged(c, "FOO", "bar");
91+
c.__calls.replace = 0;
92+
93+
const changed = applyReplaceIfChanged(c, "FOO", "bar");
94+
95+
assert.strictEqual(changed, false);
96+
assert.strictEqual(c.__calls.replace, 0);
97+
});
98+
99+
test("writes when value differs", () => {
100+
const c = createFakeCollection();
101+
applyReplaceIfChanged(c, "FOO", "bar");
102+
c.__calls.replace = 0;
103+
104+
const changed = applyReplaceIfChanged(c, "FOO", "baz");
105+
106+
assert.strictEqual(changed, true);
107+
assert.strictEqual(c.__calls.replace, 1);
108+
assert.strictEqual(c.get("FOO")!.value, "baz");
109+
});
110+
111+
test("overrides an existing Append mutator", () => {
112+
const c = createFakeCollection();
113+
applyAppendIfChanged(c, "FOO", "bar");
114+
c.__calls.replace = 0;
115+
116+
const changed = applyReplaceIfChanged(c, "FOO", "bar");
117+
118+
assert.strictEqual(changed, true);
119+
assert.strictEqual(c.__calls.replace, 1);
120+
assert.strictEqual(c.get("FOO")!.type, vscode.EnvironmentVariableMutatorType.Replace);
121+
});
122+
123+
test("writes when options differ from existing mutator", () => {
124+
const c = createFakeCollection();
125+
applyReplaceIfChanged(c, "FOO", "bar", { applyAtProcessCreation: true, applyAtShellIntegration: false });
126+
c.__calls.replace = 0;
127+
128+
const changed = applyReplaceIfChanged(c, "FOO", "bar", { applyAtProcessCreation: false, applyAtShellIntegration: false });
129+
130+
assert.strictEqual(changed, true);
131+
assert.strictEqual(c.__calls.replace, 1);
132+
});
133+
134+
test("treats omitted options as the documented defaults", () => {
135+
const c = createFakeCollection();
136+
applyReplaceIfChanged(c, "FOO", "bar", { applyAtProcessCreation: true, applyAtShellIntegration: false });
137+
c.__calls.replace = 0;
138+
139+
const changed = applyReplaceIfChanged(c, "FOO", "bar");
140+
141+
assert.strictEqual(changed, false);
142+
assert.strictEqual(c.__calls.replace, 0);
143+
});
144+
});
145+
146+
suite("applyAppendIfChanged", () => {
147+
test("writes when variable is missing", () => {
148+
const c = createFakeCollection();
149+
const changed = applyAppendIfChanged(c, "PATH", ";C:\\extra");
150+
assert.strictEqual(changed, true);
151+
assert.strictEqual(c.__calls.append, 1);
152+
assert.strictEqual(c.get("PATH")!.value, ";C:\\extra");
153+
assert.strictEqual(c.get("PATH")!.type, vscode.EnvironmentVariableMutatorType.Append);
154+
});
155+
156+
test("is a no-op when value already matches", () => {
157+
const c = createFakeCollection();
158+
applyAppendIfChanged(c, "PATH", ";C:\\extra");
159+
c.__calls.append = 0;
160+
161+
const changed = applyAppendIfChanged(c, "PATH", ";C:\\extra");
162+
163+
assert.strictEqual(changed, false);
164+
assert.strictEqual(c.__calls.append, 0);
165+
});
166+
167+
test("writes when value differs", () => {
168+
const c = createFakeCollection();
169+
applyAppendIfChanged(c, "PATH", ";C:\\extra");
170+
c.__calls.append = 0;
171+
172+
const changed = applyAppendIfChanged(c, "PATH", ";C:\\other");
173+
174+
assert.strictEqual(changed, true);
175+
assert.strictEqual(c.__calls.append, 1);
176+
assert.strictEqual(c.get("PATH")!.value, ";C:\\other");
177+
});
178+
179+
test("overrides an existing Replace mutator on the same variable", () => {
180+
const c = createFakeCollection();
181+
applyReplaceIfChanged(c, "PATH", ";C:\\extra");
182+
c.__calls.append = 0;
183+
184+
const changed = applyAppendIfChanged(c, "PATH", ";C:\\extra");
185+
186+
assert.strictEqual(changed, true);
187+
assert.strictEqual(c.__calls.append, 1);
188+
assert.strictEqual(c.get("PATH")!.type, vscode.EnvironmentVariableMutatorType.Append);
189+
});
190+
});
191+
192+
suite("regression: issue #1647 - repeated activations stay quiet", () => {
193+
test("re-applying the same set of variables does not touch the collection", () => {
194+
const c = createFakeCollection();
195+
196+
// Simulate first activation.
197+
applyReplaceIfChanged(c, "VSCODE_JDWP_ADAPTER_ENDPOINTS", "/tmp/endpoint-abc.txt");
198+
applyReplaceIfChanged(c, "VSCODE_JAVA_EXEC", "/opt/jdk/bin/java");
199+
applyAppendIfChanged(c, "PATH", ":/ext/bundled/scripts/noConfigScripts");
200+
201+
const callsAfterFirst = { ...c.__calls };
202+
assert.deepStrictEqual(callsAfterFirst, { replace: 2, append: 1, delete: 0 });
203+
204+
// Simulate a window reload: same values, same order.
205+
applyReplaceIfChanged(c, "VSCODE_JDWP_ADAPTER_ENDPOINTS", "/tmp/endpoint-abc.txt");
206+
applyReplaceIfChanged(c, "VSCODE_JAVA_EXEC", "/opt/jdk/bin/java");
207+
applyAppendIfChanged(c, "PATH", ":/ext/bundled/scripts/noConfigScripts");
208+
209+
// Nothing should have been written on the second pass.
210+
assert.deepStrictEqual(c.__calls, callsAfterFirst);
211+
});
212+
});
213+
});

0 commit comments

Comments
 (0)