diff --git a/README.md b/README.md index 767764ad..56fb3dce 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ debugjava -cp bin com.example.Main arg1 arg2 The debugger will automatically attach. See [No-Config Debug Documentation](bundled/scripts/noConfigScripts/README.md) for more details. +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. + ## 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: @@ -164,6 +166,7 @@ See [Language Model Tool Documentation](bundled/agents/README.md) for more detai - `auto` - Automatically apply the changes after compilation. This only works when `'java.autobuild.enabled'` is on. - `never` - Never apply the changes. - `java.debug.settings.enableRunDebugCodeLens`: enable the code lens provider for the run and debug buttons over main entry points, defaults to `true`. +- `java.debug.settings.enableNoConfigDebug`: enable automatic attachment through `debugjava` and the AI `debug_java_application` tool, defaults to `true`. Can be set at user or workspace level. Changes require reloading VS Code and recreating existing terminals; standard Java launch/attach debugging is unaffected. - `java.debug.settings.forceBuildBeforeLaunch`: force building the workspace before launching java program, defaults to `true`. - `java.debug.settings.onBuildFailureProceed`: Force to proceed when build fails, defaults to false. - `java.debug.settings.console`: The specified console to launch Java program, defaults to `integratedTerminal`. If you want to customize the console for a specific debug session, please modify the 'console' config in launch.json. diff --git a/bundled/agents/README.md b/bundled/agents/README.md index 3dab2c1d..9fc2050c 100644 --- a/bundled/agents/README.md +++ b/bundled/agents/README.md @@ -165,6 +165,8 @@ Make sure the Java project is properly loaded. Check that: ### Debug Session Won't Start +The `debug_java_application` tool requires `java.debug.settings.enableNoConfigDebug` (enabled by default). If you disable this setting, reload VS Code and recreate existing terminals. The launch tool then returns an explanatory message without running `debugjava`; tools that inspect or control existing debug sessions remain available. + Ensure: - Your project compiles successfully - No other debug session is running diff --git a/bundled/scripts/noConfigScripts/README.md b/bundled/scripts/noConfigScripts/README.md index 021f0553..caa82480 100644 --- a/bundled/scripts/noConfigScripts/README.md +++ b/bundled/scripts/noConfigScripts/README.md @@ -11,6 +11,20 @@ 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. +## 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: + +```json +"java.debug.settings.enableNoConfigDebug": false +``` + +Reload VS Code and recreate existing terminals after changing this setting. The value is read once during extension activation; there is no live enable/disable switch. Existing terminal processes retain their old environment until they are recreated. + +When disabled, the extension skips endpoint storage, file watching, Java executable detection, and wrapper permission setup, and removes this feature's cached terminal environment contributions. It does not clear the user's PATH or delete endpoint files as part of opting out. Standard Java launch/attach debugging, F5, and Run/Debug CodeLens are unaffected. + +The AI `debug_java_application` tool requires this integration. When disabled, it returns instructions to enable the setting instead of building, launching a terminal, or stopping an existing debug session. Other AI tools for existing debug sessions remain available. + ## Usage ### Basic Usage @@ -72,8 +86,16 @@ debugjava -jar myapp.jar --spring.profiles.active=dev 6. The port is written to a communication file 7. VS Code's file watcher detects the file and automatically starts an attach debug session +The communication file is stored in the extension's workspace-specific VS Code storage directory, not its installation directory. It contains only the local debug host and port, is deleted after a successful attach, and any stale file is removed before the next registration. Its path remains stable across window reloads. + ## Troubleshooting +### No-Config Debug Could Not Be Initialized + +If the workspace storage directory or its file watcher cannot be initialized, the extension displays a warning and leaves standard Java launch/attach debugging available. No-Config Debug requires an open workspace and writable VS Code workspace storage. + +After upgrading from a version that stored the communication file in the extension directory, recreate existing terminals to pick up the new endpoint path. + ### Port Already in Use If you see "Address already in use", another Java debug session is running. Terminate it first. diff --git a/package.json b/package.json index 6213838a..ba83874e 100644 --- a/package.json +++ b/package.json @@ -855,6 +855,12 @@ "description": "%java.debugger.configuration.enableRunDebugCodeLens.description%", "default": true }, + "java.debug.settings.enableNoConfigDebug": { + "type": "boolean", + "description": "%java.debugger.configuration.enableNoConfigDebug.description%", + "default": true, + "scope": "window" + }, "java.debug.settings.forceBuildBeforeLaunch": { "type": "boolean", "description": "%java.debugger.configuration.forceBuildBeforeLaunch%", diff --git a/package.nls.es.json b/package.nls.es.json index 06b87c18..b5b8874c 100644 --- a/package.nls.es.json +++ b/package.nls.es.json @@ -52,6 +52,7 @@ "java.debugger.configuration.numericPrecision.description": "La precisión en el formato de números reales en la vista \"Variables\" o \"Consola de Depuración\".", "java.debugger.configuration.hotCodeReplace.description": "Recargar las clases de Java modificadas durante la depuración. Asegúrate de que 'java.autobuild.enabled' no esté desactivado.", "java.debugger.configuration.enableRunDebugCodeLens.description": "Habilitar proveedores de CodeLens para la ejecución y depuración sobre los métodos principales.", + "java.debugger.configuration.enableNoConfigDebug.description": "Habilitar la conexión automática del depurador mediante el comando de terminal debugjava y la herramienta de IA debug_java_application. La depuración estándar de Java no se ve afectada. Los cambios requieren recargar VS Code y volver a crear los terminales existentes.", "java.debugger.configuration.forceBuildBeforeLaunch": "Forzar la compilación del área de trabajo antes de lanzar el programa Java.", "java.debugger.configuration.onBuildFailureProceed": "Forzar continuar cuando falla la compilación.", "java.debugger.configuration.console": "La consola establecida para lanzar el programa Java. Si quieres personalizar la consola para una sesión de depuración específica, por favor modifica la configuración de la 'console' en launch.json.", diff --git a/package.nls.it.json b/package.nls.it.json index 9279a2bd..761d8829 100644 --- a/package.nls.it.json +++ b/package.nls.it.json @@ -6,5 +6,6 @@ "java.debugger.configuration.showQualifiedNames.description": "Mostra nome completo delle classi nella scheda \"variabili\".", "java.debugger.configuration.maxStringLength.description": "Lunghezza massima delle stringhe visualizzate nella scheda \"Variabili\" o \"Console di Debug\", stringhe più lunghe di questo numero verranno tagliate, se 0 nessun taglio viene eseguito.", "java.debugger.configuration.enableRunDebugCodeLens.description": "Abilitare i provider di lenti di codice run e debug sui metodi principali.", + "java.debugger.configuration.enableNoConfigDebug.description": "Abilita la connessione automatica del debugger tramite il comando da terminale debugjava e lo strumento IA debug_java_application. Il debug Java standard non viene modificato. Le modifiche richiedono di ricaricare VS Code e ricreare i terminali esistenti.", "java.debugger.configuration.suspendAllThreads.description": "Sospende tutti i thread quando si raggiunge un punto di interruzione o ci si ferma per un'eccezione. Ha effetto solo nelle nuove sessioni di debug; le modifiche durante una sessione in esecuzione non si applicano." } diff --git a/package.nls.json b/package.nls.json index 8634a297..4aefc031 100644 --- a/package.nls.json +++ b/package.nls.json @@ -62,6 +62,7 @@ "java.debugger.configuration.numericPrecision.description": "The precision when formatting doubles in \"Variables\" or \"Debug Console\" viewlet.", "java.debugger.configuration.hotCodeReplace.description": "Reload the changed Java classes during debugging.", "java.debugger.configuration.enableRunDebugCodeLens.description": "Enable the run and debug code lens providers over main methods.", + "java.debugger.configuration.enableNoConfigDebug.description": "Enable automatic attachment through the debugjava terminal command and the AI debug_java_application tool. Standard Java launch/attach debugging is unaffected. Changes require reloading VS Code and recreating existing terminals.", "java.debugger.configuration.forceBuildBeforeLaunch": "Force building the workspace before launching java program.", "java.debugger.configuration.onBuildFailureProceed": "Force to proceed when build fails", "java.debugger.configuration.console": "The specified console to launch Java program. If you want to customize the console for a specific debug session, please modify the 'console' config in launch.json.", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 75d1406a..beea3458 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -60,6 +60,7 @@ "java.debugger.configuration.maxStringLength.description": "设定“变量”或“调试控制台”视图中显示的字符串最大长度,长度超过部分将被剪掉。如果值为0,则不执行修剪。", "java.debugger.configuration.hotCodeReplace.description": "在调试期间重新加载已更改的Java类。", "java.debugger.configuration.enableRunDebugCodeLens.description": "在main方法上启用CodeLens标记。", + "java.debugger.configuration.enableNoConfigDebug.description": "启用通过终端 debugjava 命令和 AI debug_java_application 工具自动附加调试器的功能。不影响标准 Java 启动和附加调试。修改后需要重新加载 VS Code 并重新创建现有终端。", "java.debugger.configuration.forceBuildBeforeLaunch": "在启动java程序之前强制编译整个工作空间。", "java.debugger.configuration.console": "指定的控制台用于启动Java程序。如果要为特定的调试会话自定义控制台,请修改launch.json中的“console”配置。", "java.debugger.configuration.exceptionBreakpoint.exceptionTypes": "指定要中断的一组异常类型,例如 java.lang.NullPointerException。可以为捕获的异常、未捕获的异常或两者都选择一个特定的异常类型及其子类。", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index 4261c873..852b988b 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -60,6 +60,7 @@ "java.debugger.configuration.maxStringLength.description": "設定「變數」或「偵錯主控台」視圖中顯示的字元串最大長度,長度超過部分將被剪掉。如果值為0,則不執行修剪。", "java.debugger.configuration.hotCodeReplace.description": "在偵錯期間重新載入已更改的 Java 類別。確保未停用 'java.autobuild.enabled'。", "java.debugger.configuration.enableRunDebugCodeLens.description": "在 main 方法上啟用 CodeLens 標記。", + "java.debugger.configuration.enableNoConfigDebug.description": "啟用透過終端機 debugjava 命令和 AI debug_java_application 工具自動附加偵錯器的功能。不影響標準 Java 啟動和附加偵錯。修改後需要重新載入 VS Code 並重新建立現有終端機。", "java.debugger.configuration.forceBuildBeforeLaunch": "在啟動 Java 程式之前強制編譯整個工作空間。", "java.debugger.configuration.console": "指定用於啟動 Java 程式的主控台。如果要為特定的偵錯會話自訂義主控台,請修改 launch.json 中的「console」設定。", "java.debugger.configuration.exceptionBreakpoint.skipClasses": "當發生異常時,跳過指定的類別。你可以使用內建變數,如 '$JDK' 和 '$Libraries' 來跳過一組類別,或者添加一個特定的類別名表達式,如 java.*,*.Foo。", diff --git a/src/constants.ts b/src/constants.ts index 206dd470..7c773a23 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,6 +5,7 @@ export const JAVA_LANGID: string = "java"; export const TELEMETRY_EVENT = "telemetry"; export const HCR_EVENT = "hotcodereplace"; export const USER_NOTIFICATION_EVENT = "usernotification"; +export const ENABLE_NO_CONFIG_DEBUG = "java.debug.settings.enableNoConfigDebug"; export enum ClasspathVariable { Auto = "$Auto", diff --git a/src/extension.ts b/src/extension.ts index 64b1c094..7696331c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,7 +9,7 @@ import { dispose as disposeTelemetryWrapper, initializeFromJsonFile, instrumentO instrumentOperationAsVsCodeCommand, sendInfo, setUserError } from "vscode-extension-telemetry-wrapper"; import * as commands from "./commands"; import { JavaDebugConfigurationProvider, lastUsedLaunchConfig } from "./configurationProvider"; -import { HCR_EVENT, JAVA_LANGID, TELEMETRY_EVENT, USER_NOTIFICATION_EVENT } from "./constants"; +import { ENABLE_NO_CONFIG_DEBUG, HCR_EVENT, JAVA_LANGID, TELEMETRY_EVENT, USER_NOTIFICATION_EVENT } from "./constants"; import { NotificationBar } from "./customWidget"; import { initializeCodeLensProvider, startDebugging } from "./debugCodeLensProvider"; import { initExpService } from "./experimentationService"; @@ -37,15 +37,20 @@ export async function activate(context: vscode.ExtensionContext): Promise { await initializeFromJsonFile(context.asAbsolutePath("./package.json")); await initExpService(context); - // Register No-Config Debug functionality + // Capture once so terminal integration and the AI launch tool both require a reload to change. + const noConfigDebugEnabled = vscode.workspace.getConfiguration().get(ENABLE_NO_CONFIG_DEBUG, true); const noConfigDisposable = await registerNoConfigDebug( context.environmentVariableCollection, - context.extensionPath + context.extensionPath, + context.storageUri, + noConfigDebugEnabled, ); - context.subscriptions.push(noConfigDisposable); + if (noConfigDisposable) { + context.subscriptions.push(noConfigDisposable); + } // Register Language Model Tools after Java Language Server is ready - registerLanguageModelToolsWhenReady(context); + registerLanguageModelToolsWhenReady(context, noConfigDebugEnabled); return instrumentOperation("activation", initializeExtension)(context); } @@ -112,7 +117,7 @@ const delay = promisify(setTimeout); * The debug tools depend on JDT.LS for compilation, classpath resolution, * and executing debug server commands. */ -async function registerLanguageModelToolsWhenReady(context: vscode.ExtensionContext): Promise { +async function registerLanguageModelToolsWhenReady(context: vscode.ExtensionContext, noConfigDebugEnabled: boolean): Promise { // Check if Language Model API is available if (!vscode.lm || typeof vscode.lm.registerTool !== 'function') { return; @@ -124,7 +129,7 @@ async function registerLanguageModelToolsWhenReady(context: vscode.ExtensionCont } // Register Language Model Tools for AI-assisted debugging - registerLanguageModelTool(context); + registerLanguageModelTool(context, noConfigDebugEnabled); const debugToolsDisposables = registerDebugSessionTools(context); context.subscriptions.push(...debugToolsDisposables); diff --git a/src/languageModelTool.ts b/src/languageModelTool.ts index 341fb7e6..cdfa6dfb 100644 --- a/src/languageModelTool.ts +++ b/src/languageModelTool.ts @@ -4,6 +4,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; +import { ENABLE_NO_CONFIG_DEBUG } from "./constants"; import { beginDebugSessionInvocation, classifyBreakpoint, @@ -112,7 +113,10 @@ interface LanguageModelTool { * Registers the Language Model Tool for debugging Java applications. * This allows AI assistants to help users debug Java code by invoking the debugjava command. */ -export function registerLanguageModelTool(context: vscode.ExtensionContext): vscode.Disposable | undefined { +export function registerLanguageModelTool( + context: Pick, + noConfigDebugEnabled: boolean = true, +): vscode.Disposable | undefined { // Check if the Language Model API is available const lmApi = (vscode as any).lm; if (!lmApi || typeof lmApi.registerTool !== 'function') { @@ -122,6 +126,16 @@ export function registerLanguageModelTool(context: vscode.ExtensionContext): vsc const tool: LanguageModelTool = { async invoke(options: { input: DebugJavaApplicationInput }, token: vscode.CancellationToken): Promise { + if (!noConfigDebugEnabled) { + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Java No-Config Debug is disabled by ${ENABLE_NO_CONFIG_DEBUG}. ` + + "To use this tool, enable that setting, reload VS Code, and recreate existing terminals. " + + "Standard Java launch/attach debugging remains available.", + ), + ]); + } + const startedAt = Date.now(); const targetType = classifyTarget(options.input.target); const attempt = nextAttempt(TOOL_NAMES.DEBUG_JAVA_APPLICATION); diff --git a/src/noConfigDebugInit.ts b/src/noConfigDebugInit.ts index a46eafc4..f51a2c44 100644 --- a/src/noConfigDebugInit.ts +++ b/src/noConfigDebugInit.ts @@ -3,7 +3,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as crypto from 'crypto'; import * as vscode from 'vscode'; import { sendInfo, sendError } from "vscode-extension-telemetry-wrapper"; @@ -13,6 +12,17 @@ import { applyAppendIfChanged, applyReplaceIfChanged } from "./envVarSync"; const ENV_VAR_COLLECTION_DESCRIPTION = "Java No-Config Debug"; +function clearNoConfigDebugEnvironment(collection: vscode.EnvironmentVariableCollection): void { + for (const variable of ["VSCODE_JDWP_ADAPTER_ENDPOINTS", "VSCODE_JAVA_EXEC", "PATH"]) { + if (collection.get(variable)) { + collection.delete(variable); + } + } + if (collection.description !== undefined) { + collection.description = undefined; + } +} + /** * Ensures the POSIX no-config debug wrapper can be invoked from a terminal. * @@ -47,6 +57,9 @@ export async function ensureDebugJavaScriptExecutable( * * @param envVarCollection - The collection of environment variables to be modified. * @param extPath - The path to the extension directory. + * @param storageUri - The workspace-specific storage directory provided by VS Code. + * @param enabled - Whether no-config debugging is enabled for this activation. + * @returns The registration, or undefined when no-config debugging is unavailable. * * Environment Variables: * - `VSCODE_JDWP_ADAPTER_ENDPOINTS`: Path to the file containing the debugger adapter endpoint. @@ -56,96 +69,56 @@ export async function ensureDebugJavaScriptExecutable( export async function registerNoConfigDebug( envVarCollection: vscode.EnvironmentVariableCollection, extPath: string, -): Promise { + storageUri: vscode.Uri | undefined, + enabled: boolean = true, +): Promise { const collection = envVarCollection; - // create a temp directory for the noConfigDebugAdapterEndpoints - // file path format: extPath/.noConfigDebugAdapterEndpoints/endpoint-stableWorkspaceHash.txt - let workspaceString = vscode.workspace.workspaceFile?.fsPath; - if (!workspaceString) { - workspaceString = vscode.workspace.workspaceFolders?.map((e) => e.uri.fsPath).join(';'); + if (!enabled) { + clearNoConfigDebugEnvironment(collection); + return undefined; } - if (!workspaceString) { + + if (!storageUri) { + clearNoConfigDebugEnvironment(collection); const error: Error = { name: "NoConfigDebugError", message: '[Java Debug] No workspace folder found', }; sendError(error); - return Promise.resolve(new vscode.Disposable(() => { })); - } - - // create a stable hash for the workspace folder, reduce terminal variable churn - const hash = crypto.createHash('sha256'); - hash.update(workspaceString.toString()); - const stableWorkspaceHash = hash.digest('hex').slice(0, 16); - - const tempDirPath = path.join(extPath, '.noConfigDebugAdapterEndpoints'); - const tempFilePath = path.join(tempDirPath, `endpoint-${stableWorkspaceHash}.txt`); - - // create the temp directory if it doesn't exist - if (!fs.existsSync(tempDirPath)) { - fs.mkdirSync(tempDirPath, { recursive: true }); - } else { - // remove endpoint file in the temp directory if it exists (async to avoid blocking) - if (fs.existsSync(tempFilePath)) { - fs.promises.unlink(tempFilePath).catch((err) => { - const error: Error = { - name: "NoConfigDebugError", - message: `[Java Debug] Failed to cleanup old endpoint file: ${err}`, - }; - sendError(error); - }); - } + return undefined; } - // Surface a description in VS Code's environment variable UI so users can - // see which extension is contributing these variables. - if (collection.description !== ENV_VAR_COLLECTION_DESCRIPTION) { - collection.description = ENV_VAR_COLLECTION_DESCRIPTION; - } - - // Apply our managed variables using diff-aware helpers. On a typical - // window reload the values are unchanged and these calls are no-ops, so - // VS Code does not prompt the user to restart their existing terminals. - // See issue #1647. - // - // Note: We do NOT set JAVA_TOOL_OPTIONS globally to avoid affecting all Java processes - // (javac, maven, gradle, language server, etc.). Instead, JAVA_TOOL_OPTIONS is set - // 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 - } + // Workspace storage is stable across reloads and does not require a writable + // extension installation directory (for example, the Nix store). + const tempDirPath = path.join(storageUri.fsPath, '.noConfigDebugAdapterEndpoints'); + const tempFilePath = path.join(tempDirPath, 'endpoint.txt'); + let fileSystemWatcher: vscode.FileSystemWatcher; - const noConfigScriptsDir = path.join(extPath, 'bundled', 'scripts', 'noConfigScripts'); - const debugJavaScriptPath = path.join(noConfigScriptsDir, "debugjava"); try { - await ensureDebugJavaScriptExecutable(debugJavaScriptPath); - } catch (err) { - const error: Error = { + await fs.promises.mkdir(tempDirPath, { recursive: true, mode: 0o700 }); + // Finish removing stale data before watching or publishing the endpoint. + await fs.promises.unlink(tempFilePath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") { + throw error; + } + }); + fileSystemWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(tempDirPath, path.basename(tempFilePath)), + ); + } catch (error: unknown) { + clearNoConfigDebugEnvironment(collection); + // Filesystem error messages can contain user paths; report only the error code. + const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : "unknown"; + sendError({ name: "NoConfigDebugError", - message: `[Java Debug] Failed to make debugjava executable: ${err}`, - }; - sendError(error); + message: `[Java Debug] No-config debug initialization failed (${code}).`, + }); + vscode.window.showWarningMessage( + "Java No-Config Debug could not be initialized. Standard Java debugging is still available.", + ); + return undefined; } - applyAppendIfChanged(collection, 'PATH', buildNoConfigPathAppendValue(noConfigScriptsDir)); - - // create file system watcher for the debuggerAdapterEndpointFolder for when the communication port is written - const fileSystemWatcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(tempDirPath, '**/*.txt') - ); // Track active debug sessions to prevent duplicates const activeDebugSessions = new Set(); @@ -264,7 +237,8 @@ export async function registerNoConfigDebug( }); }; - // Listen for both file creation and modification events + // Listen before publishing the endpoint or awaiting Java/script setup. + // Terminals surviving a reload may already have the stable endpoint path. const fileCreationEvent = fileSystemWatcher.onDidCreate(handleEndpointFile); const fileChangeEvent = fileSystemWatcher.onDidChange(handleEndpointFile); @@ -277,6 +251,50 @@ export async function registerNoConfigDebug( } }); + // Surface a description in VS Code's environment variable UI so users can + // see which extension is contributing these variables. + if (collection.description !== ENV_VAR_COLLECTION_DESCRIPTION) { + collection.description = ENV_VAR_COLLECTION_DESCRIPTION; + } + + // Apply our managed variables using diff-aware helpers. On a typical + // window reload the values are unchanged and these calls are no-ops, so + // VS Code does not prompt the user to restart their existing terminals. + // See issue #1647. + // + // Note: We do NOT set JAVA_TOOL_OPTIONS globally to avoid affecting all Java processes + // (javac, maven, gradle, language server, etc.). Instead, JAVA_TOOL_OPTIONS is set + // 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 { + await ensureDebugJavaScriptExecutable(debugJavaScriptPath); + } catch (err) { + const error: Error = { + name: "NoConfigDebugError", + message: `[Java Debug] Failed to make debugjava executable: ${err}`, + }; + sendError(error); + } + applyAppendIfChanged(collection, 'PATH', buildNoConfigPathAppendValue(noConfigScriptsDir)); + return Promise.resolve( new vscode.Disposable(() => { fileSystemWatcher.dispose(); diff --git a/test/envVarSync.test.ts b/test/envVarSync.test.ts index f549b92b..e6ad41a6 100644 --- a/test/envVarSync.test.ts +++ b/test/envVarSync.test.ts @@ -8,71 +8,7 @@ import { applyAppendIfChanged, applyReplaceIfChanged, } from "../src/envVarSync"; - -interface FakeMutator { - type: vscode.EnvironmentVariableMutatorType; - value: string; - options: vscode.EnvironmentVariableMutatorOptions; -} - -interface FakeCollection extends vscode.EnvironmentVariableCollection { - __calls: { replace: number; append: number; delete: number }; -} - -function createFakeCollection(): FakeCollection { - const store = new Map(); - const calls = { replace: 0, append: 0, delete: 0 }; - - const collection = { - persistent: true, - description: undefined as string | vscode.MarkdownString | undefined, - get(name: string): FakeMutator | undefined { - return store.get(name); - }, - replace(name: string, value: string, options?: vscode.EnvironmentVariableMutatorOptions): void { - calls.replace += 1; - store.set(name, { - type: vscode.EnvironmentVariableMutatorType.Replace, - value, - options: { applyAtProcessCreation: true, applyAtShellIntegration: false, ...options }, - }); - }, - append(name: string, value: string, options?: vscode.EnvironmentVariableMutatorOptions): void { - calls.append += 1; - store.set(name, { - type: vscode.EnvironmentVariableMutatorType.Append, - value, - options: { applyAtProcessCreation: true, applyAtShellIntegration: false, ...options }, - }); - }, - prepend(name: string, value: string, options?: vscode.EnvironmentVariableMutatorOptions): void { - store.set(name, { - type: vscode.EnvironmentVariableMutatorType.Prepend, - value, - options: { applyAtProcessCreation: true, applyAtShellIntegration: false, ...options }, - }); - }, - delete(name: string): void { - calls.delete += 1; - store.delete(name); - }, - clear(): void { - store.clear(); - }, - forEach(callback: (variable: string, mutator: FakeMutator, collection: any) => void): void { - store.forEach((mutator, variable) => callback(variable, mutator, collection)); - }, - getScoped(): vscode.EnvironmentVariableCollection { - return collection as unknown as vscode.EnvironmentVariableCollection; - }, - *[Symbol.iterator](): IterableIterator<[string, FakeMutator]> { - yield* store.entries(); - }, - __calls: calls, - }; - - return collection as unknown as FakeCollection; -} +import { createFakeCollection } from "./helpers/environmentVariableCollection"; suite("envVarSync", () => { suite("applyReplaceIfChanged", () => { diff --git a/test/helpers/environmentVariableCollection.ts b/test/helpers/environmentVariableCollection.ts new file mode 100644 index 00000000..1af4d5df --- /dev/null +++ b/test/helpers/environmentVariableCollection.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as vscode from "vscode"; + +export interface FakeCollection extends vscode.EnvironmentVariableCollection { + __calls: { replace: number; append: number; delete: number }; +} + +export function createFakeCollection(): FakeCollection { + const store = new Map(); + const calls = { replace: 0, append: 0, delete: 0 }; + const collection: FakeCollection = { + persistent: true, + description: undefined, + get(name) { + return store.get(name); + }, + replace(name, value, options): void { + calls.replace += 1; + store.set(name, { + type: vscode.EnvironmentVariableMutatorType.Replace, + value, + options: { applyAtProcessCreation: true, applyAtShellIntegration: false, ...options }, + }); + }, + append(name, value, options): void { + calls.append += 1; + store.set(name, { + type: vscode.EnvironmentVariableMutatorType.Append, + value, + options: { applyAtProcessCreation: true, applyAtShellIntegration: false, ...options }, + }); + }, + prepend(name, value, options): void { + store.set(name, { + type: vscode.EnvironmentVariableMutatorType.Prepend, + value, + options: { applyAtProcessCreation: true, applyAtShellIntegration: false, ...options }, + }); + }, + delete(name): void { + calls.delete += 1; + store.delete(name); + }, + clear(): void { + store.clear(); + }, + forEach(callback, thisArg): void { + store.forEach((mutator, variable) => callback.call(thisArg, variable, mutator, collection)); + }, + *[Symbol.iterator](): IterableIterator<[string, vscode.EnvironmentVariableMutator]> { + yield* store.entries(); + }, + __calls: calls, + }; + return collection; +} diff --git a/test/noConfigDebugSettings.test.ts b/test/noConfigDebugSettings.test.ts new file mode 100644 index 00000000..52f440dd --- /dev/null +++ b/test/noConfigDebugSettings.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import * as telemetry from "vscode-extension-telemetry-wrapper"; + +import { ENABLE_NO_CONFIG_DEBUG } from "../src/constants"; +import { registerLanguageModelTool } from "../src/languageModelTool"; + +suite("No-Config Debug setting", () => { + test("is a default-enabled window-scoped setting with localized descriptions", async () => { + const repoRoot = path.resolve(__dirname, "../.."); + const manifest = JSON.parse(await fs.promises.readFile(path.join(repoRoot, "package.json"), "utf8")); + const setting = manifest.contributes.configuration.properties[ENABLE_NO_CONFIG_DEBUG]; + assert.strictEqual(setting.type, "boolean"); + assert.strictEqual(setting.default, true); + assert.strictEqual(setting.scope, "window"); + assert.strictEqual(vscode.workspace.getConfiguration().inspect(ENABLE_NO_CONFIG_DEBUG)?.defaultValue, true); + + const descriptionKey = "java.debugger.configuration.enableNoConfigDebug.description"; + assert.strictEqual(setting.description, `%${descriptionKey}%`); + for (const file of ["package.nls.json", "package.nls.zh-cn.json", "package.nls.zh-tw.json", "package.nls.es.json", "package.nls.it.json"]) { + const translations = JSON.parse(await fs.promises.readFile(path.join(repoRoot, file), "utf8")); + assert.strictEqual(typeof translations[descriptionKey], "string", file); + assert.ok(translations[descriptionKey].includes("debugjava"), file); + } + }); +}); + +suite("No-Config Debug AI opt-out", () => { + let registeredTool: vscode.LanguageModelTool | undefined; + let registeredName: string | undefined; + let cleanups: (() => void)[]; + let sideEffects: number; + + function overrideProperty(target: object, key: string, descriptor: PropertyDescriptor): void { + const original = Object.getOwnPropertyDescriptor(target, key); + assert.ok(original); + Object.defineProperty(target, key, { configurable: true, ...descriptor }); + cleanups.push(() => Object.defineProperty(target, key, original)); + } + + setup(() => { + registeredTool = undefined; + registeredName = undefined; + cleanups = []; + sideEffects = 0; + const registerTool: typeof vscode.lm.registerTool = (name, tool) => { + registeredName = name; + registeredTool = tool; + return new vscode.Disposable(() => { }); + }; + overrideProperty(vscode.lm, "registerTool", { value: registerTool }); + overrideProperty(telemetry, "sendInfo", { value: () => { } }); + const unexpectedSideEffect = (): never => { + sideEffects += 1; + throw new Error("The AI launch tool must not touch sessions, terminals, or builds when disabled"); + }; + overrideProperty(vscode.debug, "activeDebugSession", { get: unexpectedSideEffect }); + overrideProperty(vscode.debug, "stopDebugging", { value: unexpectedSideEffect }); + overrideProperty(vscode.window, "terminals", { get: unexpectedSideEffect }); + overrideProperty(vscode.window, "createTerminal", { value: unexpectedSideEffect }); + overrideProperty(vscode.commands, "executeCommand", { value: unexpectedSideEffect }); + }); + + teardown(() => { + for (const cleanup of cleanups.reverse()) { + cleanup(); + } + }); + + test("returns opt-in guidance before inspecting inputs or changing sessions and terminals", async () => { + overrideProperty(vscode.workspace, "getConfiguration", { + value: () => { + throw new Error("The launch tool must use the activation snapshot instead of reading live settings"); + }, + }); + const context: Pick = { subscriptions: [] }; + const disposable = registerLanguageModelTool(context, false); + assert.ok(disposable); + cleanups.push(() => disposable.dispose()); + assert.strictEqual(context.subscriptions[0], disposable); + assert.strictEqual(registeredName, "debug_java_application"); + assert.ok(registeredTool); + + const input = { + get target(): string { + throw new Error("Disabled launch must not inspect or build the target"); + }, + get workspacePath(): string { + throw new Error("Disabled launch must not inspect the workspace"); + }, + }; + const cancellation = new vscode.CancellationTokenSource(); + cleanups.push(() => cancellation.dispose()); + const result = await registeredTool.invoke({ input, toolInvocationToken: undefined }, cancellation.token); + assert.ok(result instanceof vscode.LanguageModelToolResult); + const text = result.content[0]; + assert.ok(text instanceof vscode.LanguageModelTextPart); + assert.ok(text.value.includes(ENABLE_NO_CONFIG_DEBUG)); + assert.ok(text.value.includes("reload VS Code")); + assert.ok(text.value.includes("recreate existing terminals")); + assert.ok(text.value.includes("Standard Java launch/attach debugging remains available")); + assert.strictEqual(sideEffects, 0); + }); + + test("keeps the existing launch flow enabled by default", async () => { + const context: Pick = { subscriptions: [] }; + const disposable = registerLanguageModelTool(context); + assert.ok(disposable); + cleanups.push(() => disposable.dispose()); + assert.ok(registeredTool); + const cancellation = new vscode.CancellationTokenSource(); + cancellation.cancel(); + cleanups.push(() => cancellation.dispose()); + + const result = await registeredTool.invoke({ + input: { target: "Main", workspacePath: "unused" }, + toolInvocationToken: undefined, + }, cancellation.token); + assert.ok(result instanceof vscode.LanguageModelToolResult); + const text = result.content[0]; + assert.ok(text instanceof vscode.LanguageModelTextPart); + assert.ok(text.value.includes("Operation cancelled by user")); + assert.strictEqual(text.value.includes(ENABLE_NO_CONFIG_DEBUG), false); + assert.strictEqual(sideEffects, 0); + }); +}); diff --git a/test/noConfigDebugStorage.test.ts b/test/noConfigDebugStorage.test.ts new file mode 100644 index 00000000..ae335d41 --- /dev/null +++ b/test/noConfigDebugStorage.test.ts @@ -0,0 +1,407 @@ +// 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 { registerNoConfigDebug } from "../src/noConfigDebugInit"; +import { buildNoConfigPathAppendValue } from "../src/pathUtil"; +import * as utility from "../src/utility"; +import { createFakeCollection, FakeCollection } from "./helpers/environmentVariableCollection"; + +suite("No-Config Debug workspace storage", () => { + let tempDir: string; + let extPath: string; + let storageUri: vscode.Uri; + let collection: FakeCollection; + let errors: Error[]; + let warnings: string[]; + let patterns: vscode.GlobPattern[]; + let created: vscode.EventEmitter; + let changed: vscode.EventEmitter; + let watcherDisposed: boolean; + let cleanups: (() => void)[]; + + function replaceProperty(target: T, key: K, value: T[K]): void { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + assert.ok(descriptor); + Object.defineProperty(target, key, { ...descriptor, value }); + cleanups.push(() => Object.defineProperty(target, key, descriptor)); + } + + async function register(storage: vscode.Uri | undefined = storageUri, enabled: boolean = true): Promise { + const disposable = await registerNoConfigDebug(collection, extPath, storage, enabled); + if (disposable) { + cleanups.push(() => disposable.dispose()); + } + return disposable; + } + + function endpointPath(): string { + const endpoint = collection.get("VSCODE_JDWP_ADAPTER_ENDPOINTS"); + assert.ok(endpoint); + return endpoint.value; + } + + function seedCachedEnvironment(): void { + collection.description = "Java No-Config Debug"; + collection.replace("VSCODE_JDWP_ADAPTER_ENDPOINTS", path.join(extPath, "old-endpoint.txt")); + collection.replace("VSCODE_JAVA_EXEC", "old-java"); + collection.append("PATH", buildNoConfigPathAppendValue(path.join(extPath, "old-scripts"))); + collection.replace("UNRELATED", "keep"); + } + + function assertUnavailable(disposable: vscode.Disposable | undefined, code: string): void { + assert.strictEqual(disposable, undefined); + assert.strictEqual(collection.get("VSCODE_JDWP_ADAPTER_ENDPOINTS"), undefined); + assert.strictEqual(collection.get("VSCODE_JAVA_EXEC"), undefined); + assert.strictEqual(collection.get("PATH"), undefined); + assert.strictEqual(collection.description, undefined); + assert.strictEqual(collection.get("UNRELATED")?.value, "keep"); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].message.includes(code)); + assert.strictEqual(errors[0].message.includes(tempDir), false); + assert.strictEqual(warnings.length, 1); + assert.ok(warnings[0].includes("Standard Java debugging is still available")); + } + + setup(async () => { + cleanups = []; + tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "java-debug-storage-")); + extPath = path.join(tempDir, "extension"); + storageUri = vscode.Uri.file(path.join(tempDir, "workspace-storage", "vscjava.vscode-java-debug")); + const scriptsDir = path.join(extPath, "bundled", "scripts", "noConfigScripts"); + await fs.promises.mkdir(scriptsDir, { recursive: true }); + await fs.promises.writeFile(path.join(scriptsDir, "debugjava"), "#!/bin/bash\n", { mode: 0o755 }); + collection = createFakeCollection(); + errors = []; + warnings = []; + patterns = []; + created = new vscode.EventEmitter(); + changed = new vscode.EventEmitter(); + watcherDisposed = false; + cleanups.push(() => created.dispose(), () => changed.dispose()); + + replaceProperty(utility, "getJavaHome", async () => path.join(tempDir, "jdk")); + replaceProperty(telemetry, "sendError", (error) => { errors.push(error); }); + replaceProperty(telemetry, "sendInfo", () => { }); + replaceProperty(vscode.window, "showWarningMessage", async (message: string) => { + warnings.push(message); + return undefined; + }); + replaceProperty(vscode.workspace, "createFileSystemWatcher", (pattern) => { + patterns.push(pattern); + return { + ignoreCreateEvents: false, + ignoreChangeEvents: false, + ignoreDeleteEvents: false, + onDidCreate: created.event, + onDidChange: changed.event, + onDidDelete: created.event, + dispose: () => { watcherDisposed = true; }, + }; + }); + }); + + teardown(async () => { + for (const cleanup of cleanups.reverse()) { + cleanup(); + } + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }); + + test("skips all no-config setup and clears cached contributions when disabled", async () => { + seedCachedEnvironment(); + let setupCalls = 0; + const unexpectedSetup = (): never => { + setupCalls += 1; + throw new Error("No-config setup must not run when disabled"); + }; + replaceProperty(fs.promises, "mkdir", async () => unexpectedSetup()); + replaceProperty(fs.promises, "unlink", async () => unexpectedSetup()); + replaceProperty(fs.promises, "stat", async () => unexpectedSetup()); + replaceProperty(fs.promises, "chmod", async () => unexpectedSetup()); + replaceProperty(utility, "getJavaHome", async () => unexpectedSetup()); + replaceProperty(vscode.workspace, "createFileSystemWatcher", unexpectedSetup); + replaceProperty(vscode.debug, "onDidTerminateDebugSession", unexpectedSetup); + + assert.strictEqual(await register(storageUri, false), undefined); + assert.strictEqual(setupCalls, 0); + assert.strictEqual(fs.existsSync(storageUri.fsPath), false); + assert.strictEqual(collection.get("VSCODE_JDWP_ADAPTER_ENDPOINTS"), undefined); + assert.strictEqual(collection.get("VSCODE_JAVA_EXEC"), undefined); + assert.strictEqual(collection.get("PATH"), undefined); + assert.strictEqual(collection.get("UNRELATED")?.value, "keep"); + assert.strictEqual(collection.description, undefined); + assert.strictEqual(collection.__calls.delete, 3); + assert.strictEqual(errors.length, 0); + assert.strictEqual(warnings.length, 0); + + const callsAfterDisable = { ...collection.__calls }; + assert.strictEqual(await register(storageUri, false), undefined); + assert.deepStrictEqual(collection.__calls, callsAfterDisable); + }); + + test("does not report a missing workspace when explicitly disabled", async () => { + assert.strictEqual(await registerNoConfigDebug(collection, extPath, undefined, false), undefined); + assert.strictEqual(errors.length, 0); + assert.strictEqual(warnings.length, 0); + assert.strictEqual(patterns.length, 0); + }); + + test("does not remove existing endpoint files when disabled", async () => { + const endpoint = path.join(storageUri.fsPath, ".noConfigDebugAdapterEndpoints", "endpoint.txt"); + const data = JSON.stringify({ client: { port: 12345 } }); + await fs.promises.mkdir(path.dirname(endpoint), { recursive: true }); + await fs.promises.writeFile(endpoint, data); + + assert.strictEqual(await register(storageUri, false), undefined); + assert.strictEqual(await fs.promises.readFile(endpoint, "utf8"), data); + assert.strictEqual(patterns.length, 0); + }); + + test("restores terminal integration when re-enabled on a later activation", async () => { + const first = await register(); + assert.ok(first); + first.dispose(); + assert.strictEqual(await register(storageUri, false), undefined); + + assert.ok(await register(storageUri, true)); + assert.strictEqual(endpointPath(), path.join(storageUri.fsPath, ".noConfigDebugAdapterEndpoints", "endpoint.txt")); + assert.strictEqual(collection.description, "Java No-Config Debug"); + assert.ok(collection.get("VSCODE_JAVA_EXEC")); + assert.ok(collection.get("PATH")); + assert.strictEqual(patterns.length, 2); + assert.strictEqual(errors.length, 0); + }); + + test("creates private workspace storage and keeps bundled scripts in the installation directory", async () => { + assert.ok(await register()); + assert.strictEqual(endpointPath(), path.join(storageUri.fsPath, ".noConfigDebugAdapterEndpoints", "endpoint.txt")); + assert.strictEqual(fs.existsSync(path.join(extPath, ".noConfigDebugAdapterEndpoints")), false); + assert.strictEqual( + collection.get("PATH")?.value, + buildNoConfigPathAppendValue(path.join(extPath, "bundled", "scripts", "noConfigScripts")), + ); + assert.strictEqual(collection.get("VSCODE_JAVA_EXEC")?.value, path.join(tempDir, "jdk", "bin", "java")); + assert.deepStrictEqual(patterns, [new vscode.RelativePattern(path.dirname(endpointPath()), "endpoint.txt")]); + assert.strictEqual(errors.length, 0); + if (process.platform !== "win32") { + const permissions = (await fs.promises.stat(path.dirname(endpointPath()))).mode % 0o1000; + assert.strictEqual(permissions, 0o700); + } + }); + + test("works with a read-only extension directory on POSIX", async function() { + if (process.platform === "win32") { + this.skip(); + } + await fs.promises.chmod(extPath, 0o555); + try { + assert.ok(await register()); + assert.strictEqual(fs.existsSync(path.join(extPath, ".noConfigDebugAdapterEndpoints")), false); + assert.strictEqual(errors.length, 0); + } finally { + await fs.promises.chmod(extPath, 0o755); + } + }); + + test("does not attempt to create endpoints through a dangling installation link", async () => { + extPath = path.join(tempDir, "dangling-extension"); + await fs.promises.symlink(path.join(tempDir, "missing-target"), extPath, process.platform === "win32" ? "junction" : "dir"); + try { + assert.ok(await register()); + assert.strictEqual(path.dirname(endpointPath()), path.join(storageUri.fsPath, ".noConfigDebugAdapterEndpoints")); + assert.strictEqual(warnings.length, 0); + } finally { + await fs.promises.unlink(extPath); + } + }); + + test("keeps the endpoint stable and does not mutate terminal variables on reload", async () => { + const firstRegistration = await register(); + assert.ok(firstRegistration); + const firstEndpoint = endpointPath(); + const initialCalls = { ...collection.__calls }; + firstRegistration.dispose(); + assert.strictEqual(watcherDisposed, true); + + assert.ok(await register()); + assert.strictEqual(endpointPath(), firstEndpoint); + assert.deepStrictEqual(collection.__calls, initialCalls); + }); + + test("isolates endpoints between workspace storage directories", async () => { + assert.ok(await register()); + const firstEndpoint = endpointPath(); + const otherStorage = vscode.Uri.file(path.join(tempDir, "other-workspace-storage")); + assert.ok(await register(otherStorage)); + assert.notStrictEqual(endpointPath(), firstEndpoint); + assert.strictEqual(path.dirname(endpointPath()), path.join(otherStorage.fsPath, ".noConfigDebugAdapterEndpoints")); + }); + + test("migrates cached terminal variables once without deleting unrelated contributions", async () => { + seedCachedEnvironment(); + assert.ok(await register()); + assert.strictEqual(endpointPath(), path.join(storageUri.fsPath, ".noConfigDebugAdapterEndpoints", "endpoint.txt")); + assert.strictEqual(collection.get("UNRELATED")?.value, "keep"); + assert.strictEqual(collection.__calls.delete, 0); + const initialCalls = { ...collection.__calls }; + + assert.ok(await register()); + assert.deepStrictEqual(collection.__calls, initialCalls); + }); + + test("finishes deleting a stale endpoint before creating the watcher", async () => { + const endpoint = path.join(storageUri.fsPath, ".noConfigDebugAdapterEndpoints", "endpoint.txt"); + 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) => { + assert.strictEqual(fs.existsSync(endpoint), false); + return originalCreateWatcher(pattern); + }); + assert.ok(await register()); + assert.strictEqual(fs.existsSync(endpoint), false); + }); + + for (const code of ["ENOENT", "EACCES", "EROFS"]) { + test(`isolates ${code} storage failures and clears only its cached terminal variables`, async () => { + seedCachedEnvironment(); + replaceProperty(fs.promises, "mkdir", async () => { + throw Object.assign(new Error(`mkdir '${storageUri.fsPath}'`), { code }); + }); + assertUnavailable(await register(), code); + assert.strictEqual(patterns.length, 0); + }); + } + + test("isolates stale-file cleanup failures instead of watching old endpoint data", async () => { + seedCachedEnvironment(); + replaceProperty(fs.promises, "unlink", async () => { + throw Object.assign(new Error(`unlink '${storageUri.fsPath}'`), { code: "EACCES" }); + }); + assertUnavailable(await register(), "EACCES"); + assert.strictEqual(patterns.length, 0); + }); + + test("isolates watcher initialization failures before publishing the endpoint", async () => { + seedCachedEnvironment(); + replaceProperty(vscode.workspace, "createFileSystemWatcher", () => { + throw new Error(`Cannot watch '${storageUri.fsPath}'`); + }); + assertUnavailable(await register(), "unknown"); + }); + + test("skips an empty window without falling back to the installation directory", async () => { + seedCachedEnvironment(); + const disposable = await registerNoConfigDebug(collection, extPath, undefined); + assert.strictEqual(disposable, undefined); + assert.strictEqual(collection.get("VSCODE_JDWP_ADAPTER_ENDPOINTS"), undefined); + assert.strictEqual(collection.get("VSCODE_JAVA_EXEC"), undefined); + assert.strictEqual(collection.get("PATH"), undefined); + assert.strictEqual(collection.get("UNRELATED")?.value, "keep"); + assert.strictEqual(patterns.length, 0); + assert.strictEqual(fs.existsSync(storageUri.fsPath), false); + assert.strictEqual(errors.length, 1); + assert.strictEqual(warnings.length, 0); + }); + + for (const eventType of ["create", "change"]) { + test(`handles endpoint ${eventType} events while Java-home resolution is pending`, async function() { + this.timeout(5000); + const endpoint = path.join(storageUri.fsPath, ".noConfigDebugAdapterEndpoints", "endpoint.txt"); + if (eventType === "change") { + seedCachedEnvironment(); + 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; + }); + 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) => { + resolve(debugConfiguration); + return true; + }); + timeout = setTimeout(() => reject(new Error(`Endpoint ${eventType} event was lost during initialization`)), 1500); + }); + const originalUnlink = fs.promises.unlink; + let finishCleanup: () => void = () => { }; + const cleanedUp = new Promise((resolve) => { finishCleanup = resolve; }); + replaceProperty(fs.promises, "unlink", async (file) => { + await originalUnlink(file); + finishCleanup(); + }); + + await fs.promises.writeFile(endpoint, JSON.stringify({ client: { host: "localhost", port: 54321 } })); + const emitter = eventType === "create" ? created : changed; + emitter.fire(vscode.Uri.file(endpoint)); + const configuration = await attached; + assert.ok(typeof configuration !== "string"); + assert.strictEqual(configuration.request, "attach"); + assert.strictEqual(configuration.port, 54321); + assert.strictEqual(registrationFinished, false); + await cleanedUp; + assert.strictEqual(fs.existsSync(endpoint), false); + assert.strictEqual(errors.length, 0); + } finally { + if (timeout) { + clearTimeout(timeout); + } + releaseJavaHome(path.join(tempDir, "jdk")); + await registration; + } + }); + } + + test("reads the port from workspace storage, attaches, and removes the endpoint", async () => { + assert.ok(await register()); + const endpoint = endpointPath(); + const configurations: (vscode.DebugConfiguration | string)[] = []; + replaceProperty(vscode.debug, "startDebugging", async (_folder, configuration) => { + 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) => { + await originalUnlink(file); + finishCleanup(); + }); + + await fs.promises.writeFile(endpoint, JSON.stringify({ client: { host: "localhost", port: 54321 } })); + created.fire(vscode.Uri.file(endpoint)); + await cleanedUp; + + assert.deepStrictEqual(configurations, [{ + type: "java", + request: "attach", + name: "Attach to Java (No-Config)", + hostName: "localhost", + port: 54321, + }]); + assert.strictEqual(fs.existsSync(endpoint), false); + assert.strictEqual(errors.length, 0); + }); +});