From 45e949f5285f909ccdd4d021ac0ed9d36aaec040 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 24 Sep 2026 13:27:05 +0800 Subject: [PATCH] fix: avoid activating Java during debug configuration discovery --- README.md | 2 + bundled/scripts/noConfigScripts/README.md | 4 + src/extension.ts | 31 ++-- src/noConfigDebugInit.ts | 26 ++-- src/stackTraceLinkProvider.ts | 16 +- src/utility.ts | 62 +++++++- test/javaDebugActivation.test.ts | 177 ++++++++++++++++++++++ test/javaExtensionAPI.test.ts | 144 ++++++++++++++++++ test/noConfigDebugStorage.test.ts | 140 +++++++++++++---- 9 files changed, 521 insertions(+), 81 deletions(-) create mode 100644 test/javaDebugActivation.test.ts create mode 100644 test/javaExtensionAPI.test.ts diff --git a/README.md b/README.md index 56fb3dce..9d8db08b 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ The debugger will automatically attach. See [No-Config Debug Documentation](bund No-Config Debug is enabled by default. To disable the terminal integration and the AI `debug_java_application` tool, set `"java.debug.settings.enableNoConfigDebug": false`, reload VS Code, and recreate existing terminals. Standard Java launch/attach debugging, including F5 and Run/Debug CodeLens, remains available. +Preparing the terminal integration does not start or wait for the Java language extension. The wrapper uses `JAVA_HOME`, then `VSCODE_JAVA_EXEC` when available, then `java` on `PATH`. If Java support activates later, recreate an existing terminal to use the newly discovered Java executable. + ## AI-Assisted Debugging When using GitHub Copilot Chat, you can now ask AI to help you debug Java applications! The extension provides a Language Model Tool that enables natural language debugging: diff --git a/bundled/scripts/noConfigScripts/README.md b/bundled/scripts/noConfigScripts/README.md index caa82480..d9b61c52 100644 --- a/bundled/scripts/noConfigScripts/README.md +++ b/bundled/scripts/noConfigScripts/README.md @@ -11,6 +11,10 @@ When you open a terminal in VS Code with this extension installed, the following Note: `JAVA_TOOL_OPTIONS` is NOT set globally to avoid affecting other Java tools (javac, maven, gradle). Instead, it's set only when you run the `debugjava` command. +Terminal setup does not activate or wait for Language Support for Java. Opening the debug configuration picker in a non-Java workspace therefore does not start Java just to discover its executable. If Java support is already active, its tooling JDK is contributed as `VSCODE_JAVA_EXEC`; otherwise, the integration observes activation in the background and adds it when available. Observation stops after activation or when the integration is disposed. + +The wrapper keeps its selection order: `JAVA_HOME`, then `VSCODE_JAVA_EXEC`, then `java` on `PATH`. An existing cached `VSCODE_JAVA_EXEC` is retained while Java support is unavailable. Terminals created before Java executable discovery must be recreated to receive the new value. Actual Java launch/attach still requires Java support and may activate it. + ## Disabling No-Config Debug No-Config Debug is enabled by default. To opt out for all workspaces or just the current workspace, add this to the corresponding VS Code settings: diff --git a/src/extension.ts b/src/extension.ts index 7696331c..20c821f9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -31,7 +31,6 @@ import { initializeThreadOperations } from "./threadOperations"; import * as utility from "./utility"; import { registerBreakpointCommands } from "./breakpointCommands"; import { registerVariableMenuCommands } from "./variableMenu"; -import { promisify } from "util"; export async function activate(context: vscode.ExtensionContext): Promise { await initializeFromJsonFile(context.asAbsolutePath("./package.json")); @@ -97,7 +96,7 @@ function initializeExtension(_operationId: string, context: vscode.ExtensionCont initializeHotCodeReplace(context); initializeCodeLensProvider(context); initializeThreadOperations(context); - subscribeToJavaExtensionEvents(); + subscribeToJavaExtensionEvents(context); context.subscriptions.push(vscode.languages.registerInlineValuesProvider("java", new JavaInlineValuesProvider())); return { @@ -110,8 +109,6 @@ export async function deactivate() { await disposeTelemetryWrapper(); } -const delay = promisify(setTimeout); - /** * Register Language Model Tools after Java Language Server is ready. * The debug tools depend on JDT.LS for compilation, classpath resolution, @@ -157,22 +154,11 @@ async function registerLanguageModelToolsWhenReady(context: vscode.ExtensionCont } } -async function subscribeToJavaExtensionEvents(): Promise { - const javaExt = vscode.extensions.getExtension("redhat.java"); - if (!javaExt) { - return; - } - - // wait javaExt to activate - const timeout = 30 * 60 * 1000; // wait 30 min at most - let count = 0; - while (!javaExt.isActive && count < timeout) { - await delay(1000); - count += 1000; - } - - if (javaExt.isActive) { - javaExt.exports?.onDidSourceInvalidate?.((event: any) => { +function subscribeToJavaExtensionEvents(context: vscode.ExtensionContext): void { + context.subscriptions.push(utility.onDidActivateJavaExtension((api: { + onDidSourceInvalidate?: vscode.Event<{ affectedRootPaths?: string[] }>; + } | undefined) => { + const subscription = api?.onDidSourceInvalidate?.((event) => { if (event?.affectedRootPaths?.length) { const activeDebugSession = vscode.debug.activeDebugSession; if (activeDebugSession?.type === "java") { @@ -182,7 +168,10 @@ async function subscribeToJavaExtensionEvents(): Promise { } } }); - } + if (subscription) { + context.subscriptions.push(subscription); + } + })); } function registerDebugEventListener(context: vscode.ExtensionContext) { diff --git a/src/noConfigDebugInit.ts b/src/noConfigDebugInit.ts index f51a2c44..9cffee11 100644 --- a/src/noConfigDebugInit.ts +++ b/src/noConfigDebugInit.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { sendInfo, sendError } from "vscode-extension-telemetry-wrapper"; -import { getJavaHome } from "./utility"; +import { onDidActivateJavaExtension } from "./utility"; import { buildNoConfigPathAppendValue } from "./pathUtil"; import { applyAppendIfChanged, applyReplaceIfChanged } from "./envVarSync"; @@ -237,7 +237,7 @@ export async function registerNoConfigDebug( }); }; - // Listen before publishing the endpoint or awaiting Java/script setup. + // Listen before publishing the endpoint or awaiting script setup. // Terminals surviving a reload may already have the stable endpoint path. const fileCreationEvent = fileSystemWatcher.onDidCreate(handleEndpointFile); const fileChangeEvent = fileSystemWatcher.onDidChange(handleEndpointFile); @@ -267,21 +267,6 @@ export async function registerNoConfigDebug( // only in the debugjava wrapper scripts (debugjava.ps1, debugjava.bat, debugjava) applyReplaceIfChanged(collection, 'VSCODE_JDWP_ADAPTER_ENDPOINTS', tempFilePath); - // Try to get Java executable from Java Language Server - // This ensures we use the same Java version as the project is compiled with. - // If detection fails or returns nothing, we deliberately keep any previously - // set VSCODE_JAVA_EXEC to avoid churn from transient startup failures. - try { - const javaHome = await getJavaHome(); - if (javaHome) { - const javaExec = path.join(javaHome, 'bin', 'java'); - applyReplaceIfChanged(collection, 'VSCODE_JAVA_EXEC', javaExec); - } - } catch (error) { - // If we can't get Java from Language Server, that's okay - // The wrapper script will fall back to JAVA_HOME or PATH - } - const noConfigScriptsDir = path.join(extPath, 'bundled', 'scripts', 'noConfigScripts'); const debugJavaScriptPath = path.join(noConfigScriptsDir, "debugjava"); try { @@ -294,9 +279,16 @@ export async function registerNoConfigDebug( sendError(error); } applyAppendIfChanged(collection, 'PATH', buildNoConfigPathAppendValue(noConfigScriptsDir)); + const javaExecutableSync = onDidActivateJavaExtension((api: { javaRequirement?: { java_home?: string } } | undefined) => { + const javaHome = api?.javaRequirement?.java_home; + if (javaHome) { + applyReplaceIfChanged(collection, "VSCODE_JAVA_EXEC", path.join(javaHome, "bin", "java")); + } + }); return Promise.resolve( new vscode.Disposable(() => { + javaExecutableSync.dispose(); fileSystemWatcher.dispose(); fileCreationEvent.dispose(); fileChangeEvent.dispose(); diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts index 2ddccf8b..198d469a 100644 --- a/src/stackTraceLinkProvider.ts +++ b/src/stackTraceLinkProvider.ts @@ -2,12 +2,12 @@ // Licensed under the MIT license. import { CancellationToken, commands, DocumentLink, DocumentLinkProvider, DocumentSelector, - env, ExtensionContext, languages, Position, ProviderResult, Range, TextDocument, Uri, + env, Event, ExtensionContext, languages, Position, ProviderResult, Range, TextDocument, Uri, window, workspace } from "vscode"; import { instrumentOperationAsVsCodeCommand, sendInfo } from "vscode-extension-telemetry-wrapper"; import { resolveSourceUri } from "./languageServerPlugin"; import { parseJavaStackFrame } from "./stackFrameParser"; -import { getJavaExtensionAPI, isJavaExtEnabled, ServerMode } from "./utility"; +import { onDidActivateJavaExtension, ServerMode } from "./utility"; const ANALYZE_STACK_TRACE_COMMAND = "java.debug.analyzeStackTrace"; const NAVIGATE_TO_STACK_FRAME_COMMAND = "_java.debug.navigateToStackFrame"; @@ -177,16 +177,14 @@ export function registerStackTraceLinkProvider(context: ExtensionContext): void } function registerLinkProviderWhenReady(context: ExtensionContext): void { - // Without the Java language server, frames cannot be resolved to source - nothing to linkify. - if (!isJavaExtEnabled()) { - return; - } - const doRegister = () => context.subscriptions.push( languages.registerDocumentLinkProvider(STACK_TRACE_DOCUMENT_SELECTOR, new JavaStackTraceLinkProvider()), ); - getJavaExtensionAPI().then((api) => { + context.subscriptions.push(onDidActivateJavaExtension((api: { + serverMode: string; + onDidServerModeChange: Event; + } | undefined) => { if (!api) { return; } @@ -205,5 +203,5 @@ function registerLinkProviderWhenReady(context: ExtensionContext): void { // Already in Standard mode. doRegister(); } - }); + })); } diff --git a/src/utility.ts b/src/utility.ts index 94c8e77c..c8b59554 100644 --- a/src/utility.ts +++ b/src/utility.ts @@ -177,19 +177,69 @@ export function getJavaExtensionAPI(progressReporter?: IProgressReporter): Thena throw new JavaExtensionNotEnabledError("VS Code Java Extension is not enabled."); } - return new Promise(async (resolve) => { - progressReporter?.getCancellationToken().onCancellationRequested(() => { - resolve(undefined); - }); + const token = progressReporter?.getCancellationToken(); + if (token?.isCancellationRequested) { + return Promise.resolve(undefined); + } - resolve(await extension.activate()); - }); + const activation = extension.activate(); + if (!token) { + return activation; + } + + let listener: vscode.Disposable | undefined; + return Promise.race([ + activation, + new Promise((resolve) => { + listener = token.onCancellationRequested(() => resolve(undefined)); + }), + ]).finally(() => listener?.dispose()); } export function getJavaExtension(): vscode.Extension | undefined { return vscode.extensions.getExtension(JAVA_EXTENSION_ID); } +export function onDidActivateJavaExtension(listener: (api: T) => void): vscode.Disposable { + let disposed = false; + let timer: NodeJS.Timeout | undefined; + const update = (): boolean => { + if (disposed) { + return true; + } + try { + const extension = vscode.extensions.getExtension(JAVA_EXTENSION_ID); + if (!extension?.isActive) { + return false; + } + listener(extension.exports); + } catch { + sendError({ + name: "JavaExtensionError", + message: "Could not initialize integration with the active Java extension.", + }); + } + return true; + }; + + // VS Code has no extension-activation event. Observe without activating Java, + // including when the first Java file is opened much later in this workspace. + if (!update()) { + timer = setInterval(() => { + if (update()) { + clearInterval(timer); + } + }, 1000); + timer.unref(); + } + return new vscode.Disposable(() => { + disposed = true; + if (timer) { + clearInterval(timer); + } + }); +} + export function isJavaExtEnabled(): boolean { const javaExt = vscode.extensions.getExtension(JAVA_EXTENSION_ID); return !!javaExt; diff --git a/test/javaDebugActivation.test.ts b/test/javaDebugActivation.test.ts new file mode 100644 index 00000000..cd1e86e5 --- /dev/null +++ b/test/javaDebugActivation.test.ts @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; +import * as telemetry from "vscode-extension-telemetry-wrapper"; +import { activate } from "../src/extension"; +import * as configurationProvider from "../src/configurationProvider"; +import * as experimentation from "../src/experimentationService"; +import * as languageModelTool from "../src/languageModelTool"; +import * as chatTelemetry from "../src/lmToolTelemetry"; +import { createFakeCollection } from "./helpers/environmentVariableCollection"; + +suite("Non-Java workspace activation", () => { + test("registers core debugging without starting Java, then enables integrations after natural activation", async function() { + this.timeout(10000); + const cleanups: (() => void)[] = []; + const override = (target: object, key: string, value: unknown) => { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + assert.ok(descriptor); + Object.defineProperty(target, key, { ...descriptor, value }); + cleanups.push(() => Object.defineProperty(target, key, descriptor)); + }; + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "java-debug-activation-")); + const extensionPath = path.resolve(__dirname, "../.."); + const collection = createFakeCollection(); + const commands = new Set(); + const configurations: string[] = []; + const adapters: string[] = []; + let linkProviders = 0; + let javaActive = false; + let javaActivationCalls = 0; + let sourceSubscriptions = 0; + const errors: Error[] = []; + const sourceInvalidated = new vscode.EventEmitter<{ affectedRootPaths?: string[] }>(); + const api = { + javaRequirement: { java_home: path.join(tempDir, "jdk") }, + serverMode: "Standard", + onDidSourceInvalidate: (listener: (event: { affectedRootPaths?: string[] }) => void) => { + sourceSubscriptions += 1; + const subscription = sourceInvalidated.event(listener); + return new vscode.Disposable(() => { + sourceSubscriptions -= 1; + subscription.dispose(); + }); + }, + }; + let completeJavaActivation: (value: typeof api) => void = () => { }; + const pendingJavaActivation = new Promise((resolve) => { completeJavaActivation = resolve; }); + const javaExtension: vscode.Extension = { + id: "redhat.java", + extensionPath, + extensionUri: vscode.Uri.file(extensionPath), + packageJSON: {}, + extensionKind: vscode.ExtensionKind.Workspace, + get isActive() { return javaActive; }, + get exports() { + assert.ok(javaActive, "Do not access Java exports before activation"); + return api; + }, + activate() { + javaActivationCalls += 1; + return pendingJavaActivation; + }, + }; + const context: vscode.ExtensionContext = { + subscriptions: [], + extensionPath, + extensionUri: vscode.Uri.file(extensionPath), + storageUri: vscode.Uri.file(tempDir), + environmentVariableCollection: { ...collection, getScoped: () => collection }, + extensionMode: vscode.ExtensionMode.Test, + extension: javaExtension, + asAbsolutePath: (relativePath) => path.join(extensionPath, relativePath), + get workspaceState(): never { throw new Error("Unexpected workspace state access"); }, + get globalState(): never { throw new Error("Unexpected global state access"); }, + get secrets(): never { throw new Error("Unexpected secrets access"); }, + get storagePath(): never { throw new Error("Unexpected storage path access"); }, + get globalStorageUri(): never { throw new Error("Unexpected global storage URI access"); }, + get globalStoragePath(): never { throw new Error("Unexpected global storage path access"); }, + get logUri(): never { throw new Error("Unexpected log URI access"); }, + get logPath(): never { throw new Error("Unexpected log path access"); }, + get languageModelAccessInformation(): never { throw new Error("Unexpected language model access information"); }, + }; + let activation: ReturnType | undefined; + let timeout: NodeJS.Timeout | undefined; + try { + const document = await vscode.workspace.openTextDocument({ language: "typescript", content: "export const value = 1;" }); + await vscode.window.showTextDocument(document); + override(vscode.extensions, "getExtension", (id: string) => { + assert.strictEqual(id, "redhat.java"); + return javaExtension; + }); + override(telemetry, "initializeFromJsonFile", async () => { }); + override(experimentation, "initExpService", async () => { }); + override(telemetry, "sendError", (error: Error) => errors.push(error)); + override(telemetry, "instrumentOperation", ( + _name: string, operation: (id: string, ...args: unknown[]) => unknown, + ) => (...args: unknown[]) => operation("test", ...args)); + override(telemetry, "instrumentOperationAsVsCodeCommand", (command: string) => { + commands.add(command); + return new vscode.Disposable(() => { }); + }); + override(vscode.commands, "registerCommand", (command: string) => { + commands.add(command); + return new vscode.Disposable(() => { }); + }); + override(vscode.debug, "registerDebugConfigurationProvider", (type: string) => { + configurations.push(type); + return new vscode.Disposable(() => { }); + }); + override(configurationProvider, "JavaDebugConfigurationProvider", class { }); + override(vscode.debug, "registerDebugAdapterDescriptorFactory", (type: string) => { + adapters.push(type); + return new vscode.Disposable(() => { }); + }); + override(vscode.languages, "registerDocumentLinkProvider", () => { + linkProviders += 1; + return new vscode.Disposable(() => { }); + }); + override(languageModelTool, "registerLanguageModelTool", () => undefined); + override(languageModelTool, "registerDebugSessionTools", () => []); + override(chatTelemetry, "recordChatActivation", () => { }); + + activation = activate(context); + const result = await Promise.race([ + activation, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error("Activation waited for Java")), 2000); + }), + ]); + clearTimeout(timeout); + assert.ok(result.progressProvider); + assert.deepStrictEqual(configurations, ["java"]); + assert.deepStrictEqual(adapters, ["java"]); + assert.ok(commands.has("java.debug.debugJavaFile")); + assert.ok(commands.has("java.debug.analyzeStackTrace")); + assert.strictEqual(javaActivationCalls, 0); + assert.strictEqual(linkProviders, 0); + assert.strictEqual(sourceSubscriptions, 0); + assert.ok(collection.get("PATH")); + assert.strictEqual(collection.get("VSCODE_JAVA_EXEC"), undefined); + assert.strictEqual(errors.length, 0); + + javaActive = true; + completeJavaActivation(api); + await new Promise((resolve) => setTimeout(resolve, 1100)); + assert.strictEqual(javaActivationCalls, 0); + assert.strictEqual(linkProviders, 1); + assert.strictEqual(sourceSubscriptions, 1); + assert.strictEqual(collection.get("VSCODE_JAVA_EXEC")?.value, path.join(tempDir, "jdk", "bin", "java")); + assert.strictEqual(errors.length, 0); + } finally { + if (timeout) { + clearTimeout(timeout); + } + javaActive = true; + completeJavaActivation(api); + try { + await activation; + } finally { + for (const disposable of context.subscriptions.reverse()) { + disposable.dispose(); + } + sourceInvalidated.dispose(); + for (const cleanup of cleanups.reverse()) { + cleanup(); + } + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } + assert.strictEqual(sourceSubscriptions, 0); + } + }); +}); diff --git a/test/javaExtensionAPI.test.ts b/test/javaExtensionAPI.test.ts new file mode 100644 index 00000000..fd2b96f4 --- /dev/null +++ b/test/javaExtensionAPI.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from "assert"; +import * as path from "path"; +import * as vscode from "vscode"; +import { IProgressReporter } from "../src/progressAPI"; +import { getJavaExtensionAPI, getJavaHome } from "../src/utility"; + +suite("Java extension API activation", () => { + const api = { javaRequirement: { java_home: path.join("test-jdk") } }; + let activation: Promise; + let resolveActivation: (value: typeof api) => void; + let rejectActivation: (error: Error) => void; + let activateCalls: number; + let source: vscode.CancellationTokenSource; + let listenerCount: number; + let progress: IProgressReporter; + let restoreExtension: () => void; + + setup(() => { + activation = new Promise((resolve, reject) => { + resolveActivation = resolve; + rejectActivation = reject; + }); + activateCalls = 0; + source = new vscode.CancellationTokenSource(); + listenerCount = 0; + const token: vscode.CancellationToken = { + get isCancellationRequested() { return source.token.isCancellationRequested; }, + onCancellationRequested: (listener) => { + listenerCount += 1; + const registration = source.token.onCancellationRequested(listener); + return new vscode.Disposable(() => { + listenerCount -= 1; + registration.dispose(); + }); + }, + }; + progress = { + setJobName: () => { }, + getId: () => "test", + getProgressLocation: () => vscode.ProgressLocation.Notification, + report: () => { }, + show: () => { }, + hide: () => { }, + isCancelled: () => token.isCancellationRequested, + done: () => source.cancel(), + getCancellationToken: () => token, + observe: () => { }, + }; + const extensionPath = path.resolve(__dirname, "../.."); + const extension: vscode.Extension = { + id: "redhat.java", + extensionPath, + extensionUri: vscode.Uri.file(extensionPath), + isActive: false, + packageJSON: {}, + extensionKind: vscode.ExtensionKind.Workspace, + exports: api, + activate() { + activateCalls += 1; + return activation; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(vscode.extensions, "getExtension"); + assert.ok(descriptor); + Object.defineProperty(vscode.extensions, "getExtension", { + ...descriptor, + value: (id: string) => { + assert.strictEqual(id, "redhat.java"); + return extension; + }, + }); + restoreExtension = () => Object.defineProperty(vscode.extensions, "getExtension", descriptor); + }); + + teardown(async () => { + resolveActivation(api); + await new Promise((resolve) => setImmediate(resolve)); + restoreExtension(); + source.dispose(); + }); + + test("propagates activation rejection instead of leaving getJavaHome pending", async () => { + const error = new Error("Java activation failed"); + const rejected = assert.rejects(getJavaHome(), (actual: unknown) => actual === error); + rejectActivation(error); + await rejected; + assert.strictEqual(activateCalls, 1); + }); + + test("reads the Java tooling home from the activated API", async () => { + const home = getJavaHome(); + resolveActivation(api); + assert.strictEqual(await home, api.javaRequirement.java_home); + }); + + test("removes the cancellation listener after successful activation", async () => { + const result = Promise.resolve(getJavaExtensionAPI(progress)); + assert.strictEqual(listenerCount, 1); + resolveActivation(api); + assert.strictEqual(await result, api); + assert.strictEqual(listenerCount, 0); + }); + + test("propagates activation failure with progress and removes its listener", async () => { + const error = new Error("Java activation failed with progress"); + const rejected = assert.rejects(Promise.resolve(getJavaExtensionAPI(progress)), (actual: unknown) => actual === error); + rejectActivation(error); + await rejected; + assert.strictEqual(listenerCount, 0); + }); + + test("does not activate Java for an already cancelled caller", async () => { + source.cancel(); + assert.strictEqual(await getJavaExtensionAPI(progress), undefined); + assert.strictEqual(activateCalls, 0); + assert.strictEqual(listenerCount, 0); + }); + + test("cancels one caller without cancelling another caller's activation", async () => { + const cancelled = Promise.resolve(getJavaExtensionAPI(progress)); + const other = Promise.resolve(getJavaExtensionAPI()); + let otherSettled = false; + void other.then(() => { otherSettled = true; }); + source.cancel(); + assert.strictEqual(await cancelled, undefined); + assert.strictEqual(otherSettled, false); + assert.strictEqual(listenerCount, 0); + + resolveActivation(api); + assert.strictEqual(await other, api); + }); + + test("handles a late activation rejection after caller cancellation", async () => { + const cancelled = Promise.resolve(getJavaExtensionAPI(progress)); + source.cancel(); + assert.strictEqual(await cancelled, undefined); + rejectActivation(new Error("Late activation failure")); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(listenerCount, 0); + }); +}); diff --git a/test/noConfigDebugStorage.test.ts b/test/noConfigDebugStorage.test.ts index ae335d41..3e893b34 100644 --- a/test/noConfigDebugStorage.test.ts +++ b/test/noConfigDebugStorage.test.ts @@ -25,8 +25,11 @@ suite("No-Config Debug workspace storage", () => { let changed: vscode.EventEmitter; let watcherDisposed: boolean; let cleanups: (() => void)[]; + let javaActive: boolean; + let javaApi: { javaRequirement?: { java_home?: string } }; + let javaLookups: number; - function replaceProperty(target: T, key: K, value: T[K]): void { + function replaceProperty(target: object, key: string, value: unknown): void { const descriptor = Object.getOwnPropertyDescriptor(target, key); assert.ok(descriptor); Object.defineProperty(target, key, { ...descriptor, value }); @@ -86,14 +89,36 @@ suite("No-Config Debug workspace storage", () => { watcherDisposed = false; cleanups.push(() => created.dispose(), () => changed.dispose()); - replaceProperty(utility, "getJavaHome", async () => path.join(tempDir, "jdk")); - replaceProperty(telemetry, "sendError", (error) => { errors.push(error); }); + javaActive = true; + javaApi = { javaRequirement: { java_home: path.join(tempDir, "jdk") } }; + javaLookups = 0; + replaceProperty(vscode.extensions, "getExtension", (id: string) => { + assert.strictEqual(id, "redhat.java"); + javaLookups += 1; + return { + id: "redhat.java", + extensionPath: extPath, + extensionUri: vscode.Uri.file(extPath), + packageJSON: {}, + extensionKind: vscode.ExtensionKind.Workspace, + get isActive() { return javaActive; }, + get exports() { + assert.ok(javaActive, "Must not access exports before Java activates"); + return javaApi; + }, + activate() { + assert.fail("Terminal preparation must not activate Java"); + }, + }; + }); + replaceProperty(utility, "getJavaHome", async () => assert.fail("Terminal preparation must not request Java activation")); + replaceProperty(telemetry, "sendError", (error: Error) => { errors.push(error); }); replaceProperty(telemetry, "sendInfo", () => { }); replaceProperty(vscode.window, "showWarningMessage", async (message: string) => { warnings.push(message); return undefined; }); - replaceProperty(vscode.workspace, "createFileSystemWatcher", (pattern) => { + replaceProperty(vscode.workspace, "createFileSystemWatcher", (pattern: vscode.GlobPattern) => { patterns.push(pattern); return { ignoreCreateEvents: false, @@ -126,6 +151,7 @@ suite("No-Config Debug workspace storage", () => { replaceProperty(fs.promises, "stat", async () => unexpectedSetup()); replaceProperty(fs.promises, "chmod", async () => unexpectedSetup()); replaceProperty(utility, "getJavaHome", async () => unexpectedSetup()); + replaceProperty(vscode.extensions, "getExtension", unexpectedSetup); replaceProperty(vscode.workspace, "createFileSystemWatcher", unexpectedSetup); replaceProperty(vscode.debug, "onDidTerminateDebugSession", unexpectedSetup); @@ -235,6 +261,75 @@ suite("No-Config Debug workspace storage", () => { assert.deepStrictEqual(collection.__calls, initialCalls); }); + test("prepares terminals without Java activation and discovers a later activation without reinitializing storage", async function() { + this.timeout(5000); + javaActive = false; + assert.ok(await register()); + assert.ok(collection.get("PATH")); + assert.ok(collection.get("VSCODE_JDWP_ADAPTER_ENDPOINTS")); + assert.strictEqual(collection.get("VSCODE_JAVA_EXEC"), undefined); + assert.strictEqual(errors.length, 0); + const initialCalls = { ...collection.__calls }; + + let executableUpdated: () => void = () => { }; + const updated = new Promise((resolve) => { executableUpdated = resolve; }); + const originalReplace = collection.replace.bind(collection); + replaceProperty(collection, "replace", ( + variable: string, value: string, options?: vscode.EnvironmentVariableMutatorOptions, + ) => { + originalReplace(variable, value, options); + if (variable === "VSCODE_JAVA_EXEC") { + executableUpdated(); + } + }); + javaActive = true; + await updated; + assert.strictEqual(collection.get("VSCODE_JAVA_EXEC")?.value, path.join(tempDir, "jdk", "bin", "java")); + assert.strictEqual(patterns.length, 1); + assert.strictEqual(collection.__calls.append, initialCalls.append); + assert.strictEqual(collection.__calls.replace, initialCalls.replace + 1); + const lookupsAfterActivation = javaLookups; + await new Promise((resolve) => setTimeout(resolve, 1100)); + assert.strictEqual(javaLookups, lookupsAfterActivation, "Stop observation after Java activates"); + }); + + test("preserves the cached Java executable and stops observation on disposal", async function() { + this.timeout(5000); + seedCachedEnvironment(); + javaActive = false; + const registration = await register(); + assert.ok(registration); + assert.strictEqual(collection.get("VSCODE_JAVA_EXEC")?.value, "old-java"); + registration.dispose(); + const callsAfterDisposal = { ...collection.__calls }; + const lookupsAfterDisposal = javaLookups; + javaActive = true; + await new Promise((resolve) => setTimeout(resolve, 1100)); + assert.strictEqual(javaLookups, lookupsAfterDisposal); + assert.deepStrictEqual(collection.__calls, callsAfterDisposal); + }); + + test("preserves fallback when active Java has no tooling home", async () => { + seedCachedEnvironment(); + javaApi = {}; + assert.ok(await register()); + assert.strictEqual(collection.get("VSCODE_JAVA_EXEC")?.value, "old-java"); + assert.ok(collection.get("PATH")); + assert.strictEqual(errors.length, 0); + }); + + test("reports Java API discovery errors without blocking terminal preparation or exposing paths", async () => { + seedCachedEnvironment(); + replaceProperty(vscode.extensions, "getExtension", () => { throw new Error(`Private path: ${tempDir}`); }); + assert.ok(await register()); + assert.strictEqual(collection.get("VSCODE_JAVA_EXEC")?.value, "old-java"); + assert.ok(collection.get("PATH")); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].message.includes("Could not initialize integration")); + assert.strictEqual(errors[0].message.includes(tempDir), false); + assert.strictEqual(warnings.length, 0); + }); + test("isolates endpoints between workspace storage directories", async () => { assert.ok(await register()); const firstEndpoint = endpointPath(); @@ -261,7 +356,7 @@ suite("No-Config Debug workspace storage", () => { await fs.promises.mkdir(path.dirname(endpoint), { recursive: true }); await fs.promises.writeFile(endpoint, JSON.stringify({ client: { port: 12345 } })); const originalCreateWatcher = vscode.workspace.createFileSystemWatcher; - replaceProperty(vscode.workspace, "createFileSystemWatcher", (pattern) => { + replaceProperty(vscode.workspace, "createFileSystemWatcher", (pattern: vscode.GlobPattern) => { assert.strictEqual(fs.existsSync(endpoint), false); return originalCreateWatcher(pattern); }); @@ -312,7 +407,7 @@ suite("No-Config Debug workspace storage", () => { }); for (const eventType of ["create", "change"]) { - test(`handles endpoint ${eventType} events while Java-home resolution is pending`, async function() { + test(`handles endpoint ${eventType} events before Java activates`, async function() { this.timeout(5000); const endpoint = path.join(storageUri.fsPath, ".noConfigDebugAdapterEndpoints", "endpoint.txt"); if (eventType === "change") { @@ -320,26 +415,15 @@ suite("No-Config Debug workspace storage", () => { collection.replace("VSCODE_JDWP_ADAPTER_ENDPOINTS", endpoint); } - let releaseJavaHome: (javaHome: string) => void = () => { }; - const pendingJavaHome = new Promise((resolve) => { releaseJavaHome = resolve; }); - let notifyJavaHomeRequested: () => void = () => { }; - const javaHomeRequested = new Promise((resolve) => { notifyJavaHomeRequested = resolve; }); - replaceProperty(utility, "getJavaHome", () => { - notifyJavaHomeRequested(); - return pendingJavaHome; - }); - - let registrationFinished = false; - const registration = register().then((disposable) => { - registrationFinished = true; - return disposable; - }); + javaActive = false; + assert.ok(await register()); let timeout: NodeJS.Timeout | undefined; try { - await javaHomeRequested; assert.strictEqual(endpointPath(), endpoint); const attached = new Promise((resolve, reject) => { - replaceProperty(vscode.debug, "startDebugging", async (_folder, debugConfiguration) => { + replaceProperty(vscode.debug, "startDebugging", async ( + _folder: vscode.WorkspaceFolder | undefined, debugConfiguration: vscode.DebugConfiguration | string, + ) => { resolve(debugConfiguration); return true; }); @@ -348,7 +432,7 @@ suite("No-Config Debug workspace storage", () => { const originalUnlink = fs.promises.unlink; let finishCleanup: () => void = () => { }; const cleanedUp = new Promise((resolve) => { finishCleanup = resolve; }); - replaceProperty(fs.promises, "unlink", async (file) => { + replaceProperty(fs.promises, "unlink", async (file: fs.PathLike) => { await originalUnlink(file); finishCleanup(); }); @@ -360,7 +444,7 @@ suite("No-Config Debug workspace storage", () => { assert.ok(typeof configuration !== "string"); assert.strictEqual(configuration.request, "attach"); assert.strictEqual(configuration.port, 54321); - assert.strictEqual(registrationFinished, false); + assert.strictEqual(javaActive, false); await cleanedUp; assert.strictEqual(fs.existsSync(endpoint), false); assert.strictEqual(errors.length, 0); @@ -368,8 +452,6 @@ suite("No-Config Debug workspace storage", () => { if (timeout) { clearTimeout(timeout); } - releaseJavaHome(path.join(tempDir, "jdk")); - await registration; } }); } @@ -378,14 +460,16 @@ suite("No-Config Debug workspace storage", () => { assert.ok(await register()); const endpoint = endpointPath(); const configurations: (vscode.DebugConfiguration | string)[] = []; - replaceProperty(vscode.debug, "startDebugging", async (_folder, configuration) => { + replaceProperty(vscode.debug, "startDebugging", async ( + _folder: vscode.WorkspaceFolder | undefined, configuration: vscode.DebugConfiguration | string, + ) => { configurations.push(configuration); return true; }); const originalUnlink = fs.promises.unlink; let finishCleanup: () => void = () => { }; const cleanedUp = new Promise((resolve) => { finishCleanup = resolve; }); - replaceProperty(fs.promises, "unlink", async (file) => { + replaceProperty(fs.promises, "unlink", async (file: fs.PathLike) => { await originalUnlink(file); finishCleanup(); });