diff --git a/README.md b/README.md index 56fb3dce..389d577b 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. +No-Config Debug prepares its terminal integration in the background without delaying core Run/Debug registration. The AI launch tool returns immediately with `JAVA_NOT_READY` while JDT LS is starting, or `NO_CONFIG_NOT_READY` while terminal preparation is incomplete. No launch is attempted or queued; retry after the reported prerequisite is ready. A terminal opened before preparation finishes may need to be recreated to receive the environment contributions. + ## 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/agents/README.md b/bundled/agents/README.md index 9fc2050c..ca8abeaf 100644 --- a/bundled/agents/README.md +++ b/bundled/agents/README.md @@ -167,6 +167,10 @@ Make sure the Java project is properly loaded. Check that: 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. +The launch tool checks Java and No-Config readiness without waiting for initialization. `JAVA_NOT_READY` means JDT LS has not reported ready; `NO_CONFIG_NOT_READY` means Java is ready but terminal preparation is incomplete. No build, terminal, or debug session changes are made, and no launch is queued. Report the prerequisite to the user or continue independent work; invoke the tool again only after it becomes ready, rather than polling or changing project code. + +`JAVA_INIT_FAILED` and `NO_CONFIG_INIT_FAILED` identify initialization failures, not application failures. Follow their recovery guidance instead of diagnosing output from an older debug terminal or bypassing readiness with a terminal launch. Disabled integration, cancellation, and disposal are also reported explicitly. + Ensure: - Your project compiles successfully - No other debug session is running diff --git a/bundled/agents/debug.agent.md b/bundled/agents/debug.agent.md index 168dd465..ec5b5ad9 100644 --- a/bundled/agents/debug.agent.md +++ b/bundled/agents/debug.agent.md @@ -178,7 +178,7 @@ vscjava.vscode-java-debug/getDebugSessionInfo() // Check again - should now be ### 2.4 Automatic Cleanup on Restart -**Good news:** The `debugJavaApplication` tool automatically cleans up before starting: +**Good news:** Once Java and No-Config preparation are ready, the `debugJavaApplication` tool automatically cleans up before starting: - Stops any existing Java debug session (avoids JDWP port conflicts) - Closes existing "Java Debug" terminals (avoids confusion) @@ -186,7 +186,11 @@ This means you can safely call `debugJavaApplication` again without manually sto ### 2.5 Fallback: When debugJavaApplication Fails or Times Out -When `debugJavaApplication` returns timeout or failure, follow this recovery workflow: +If `debugJavaApplication` returns `JAVA_NOT_READY` or `NO_CONFIG_NOT_READY`, no launch was attempted and existing sessions and terminals were left unchanged. Explain the prerequisite to the user or continue independent work, then retry only after readiness changes. Do not poll the launch tool, change project code, read an old terminal as evidence of a new launch failure, or bypass readiness with a terminal launch. + +For `JAVA_INIT_FAILED`, `NO_CONFIG_INIT_FAILED`, or `NO_CONFIG_DISABLED`, follow the returned initialization or setting guidance rather than the application-error workflow below. + +When `debugJavaApplication` returns an actual launch timeout or failure, follow this recovery workflow: **Step 1: Check terminal output for errors** ``` diff --git a/bundled/scripts/noConfigScripts/README.md b/bundled/scripts/noConfigScripts/README.md index caa82480..34d984c7 100644 --- a/bundled/scripts/noConfigScripts/README.md +++ b/bundled/scripts/noConfigScripts/README.md @@ -4,13 +4,27 @@ This feature enables configuration-less debugging for Java applications, similar ## How It Works -When you open a terminal in VS Code with this extension installed, the following environment variables are automatically set: +Once No-Config Debug initialization finishes, newly opened VS Code terminals receive the following environment contributions: - `VSCODE_JDWP_ADAPTER_ENDPOINTS`: Path to a communication file for port exchange - `PATH`: Includes the `debugjava` command wrapper 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. +### Startup readiness + +The extension registers core Java Run/Debug support first, then starts No-Config Debug initialization in the background. Ordinary launch/attach registration and extension activation do not wait for endpoint storage, Java executable discovery, or wrapper permission preparation. + +The AI `debug_java_application` entry is registered immediately. It observes the Java extension's `serverReady()` signal in the background, independently of terminal preparation. Invocation checks both states before inspecting launch inputs, probing Java, recording launch telemetry, building, creating a terminal, or stopping an existing debug session. It never waits for startup or queues a launch. + +If JDT LS is not ready, the tool immediately returns `JAVA_NOT_READY`. If Java is ready but terminal preparation is incomplete, it returns `NO_CONFIG_NOT_READY`. Wait for the reported prerequisite before a new invocation; do not retry in a loop or treat readiness as a project-code error. Becoming ready does not automatically launch a previously refused request. Known Java or No-Config initialization failures, disabled integration, cancellation, and disposal return distinct explanations. + +The existing `javaLSReady` tool visibility condition remains unchanged. Direct tool calls still receive explicit readiness feedback. Endpoint listeners remain eager rather than waiting for JDT LS, so surviving terminals can submit endpoints while Java starts. Directory preparation and startup cleanup run once per registration, not once per tool invocation. + +This is not lazy terminal setup: preparation still starts during activation. However, activation completing does not guarantee that `debugjava` is ready. Terminals opened before preparation finishes may lack the environment contributions and must be recreated afterward. Existing terminals are not automatically closed or repaired. + +On disposal, listeners are released immediately even if initialization is still pending. Already-started filesystem operations or Java extension activation are not forcibly cancelled, but their late completion cannot publish environment updates or register new listeners. + ## 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: @@ -104,7 +118,7 @@ If you see "Address already in use", another Java debug session is running. Term 1. Ensure you're running with `debugjava` command (not plain `java`) 2. Check that the `debugjava` command is available: `which debugjava` (Unix) or `Get-Command debugjava` (PowerShell) -3. Verify the terminal was opened AFTER the extension activated +3. Verify the terminal was opened after No-Config Debug initialization finished; recreate an early terminal if its environment is missing the contributions 4. Check the Debug Console for error messages ### Node.js Not Found diff --git a/package.json b/package.json index ba83874e..a931ca0c 100644 --- a/package.json +++ b/package.json @@ -1017,7 +1017,7 @@ { "name": "debug_java_application", "displayName": "Debug Java Application", - "modelDescription": "Launch or attach to a Java application in debug mode with automatic compilation and classpath resolution. The tool handles building the project, resolving dependencies, starting the JVM with JDWP enabled, and auto-attaching the VS Code debugger. Use this as the first step to establish a debug session. The debug process runs in the background until stopped. Example usage: Debug a main class ('com.example.Main'), a JAR file ('target/app.jar'), or with program arguments (['--port=8080']).", + "modelDescription": "Launch or attach to a Java application in debug mode with automatic compilation and classpath resolution. The tool handles building the project, resolving dependencies, starting the JVM with JDWP enabled, and auto-attaching the VS Code debugger. Use this as the first step to establish a debug session. Returns JAVA_NOT_READY or NO_CONFIG_NOT_READY immediately if startup is incomplete, without attempting or queuing a launch. Retry only after readiness changes; do not poll or change project code for a readiness result. The debug process runs in the background until stopped. Example usage: Debug a main class ('com.example.Main'), a JAR file ('target/app.jar'), or with program arguments (['--port=8080']).", "toolReferenceName": "debugJavaApplication", "tags": [ "java", diff --git a/resources/instruments/javaDebugContext.instructions.md b/resources/instruments/javaDebugContext.instructions.md index 055b01b0..efd4f33f 100644 --- a/resources/instruments/javaDebugContext.instructions.md +++ b/resources/instruments/javaDebugContext.instructions.md @@ -13,4 +13,6 @@ For Java run/launch/debug/inspection requests, prefer the Java debug language mo If both apply (e.g. "launch and break on entry of `Main.foo`"), load `java-launch-troubleshooting` first, then `java-debug-inspection` after the session is active. -Fall back to `run_in_terminal` only when `debug_java_application` returns "Java Language Server not ready" or "project not detected". +If `debug_java_application` returns `JAVA_NOT_READY` or `NO_CONFIG_NOT_READY`, no launch was attempted. Report the startup prerequisite or continue independent work, then retry only after readiness changes. Do not poll, modify project code, or use a terminal launch to bypass readiness. Follow the returned recovery guidance for initialization failures or disabled integration. + +Fall back to `run_in_terminal` when `debug_java_application` returns "project not detected". diff --git a/resources/skills/java-launch-troubleshooting/SKILL.md b/resources/skills/java-launch-troubleshooting/SKILL.md index c14dc527..8b60836a 100644 --- a/resources/skills/java-launch-troubleshooting/SKILL.md +++ b/resources/skills/java-launch-troubleshooting/SKILL.md @@ -27,13 +27,16 @@ These language model tools are contributed by the `Debugger for Java` extension 1. **Confirm intent.** Is the user trying to *run / start / launch / stop* a Java program (use this skill) or just edit code (do not load this skill)? 2. **Check existing session.** Call `get_debug_session_info` first. If a session is already running for the target, do not launch a second one. 3. **Launch.** Call `debug_java_application` with `target` = the fully qualified main class or JAR, and `workspacePath` = the project root containing `pom.xml`, `build.gradle`, or `.classpath`. Let `skipBuild` default to `false` so the tool handles compilation. -4. **Read the error.** If `debug_java_application` fails, the error message is structured (mainClass missing, classpath unresolved, build failure with line number). Use it to suggest a fix — do not retry with `run_in_terminal`. +4. **Read the result.** `JAVA_NOT_READY` and `NO_CONFIG_NOT_READY` are startup prerequisites, not project errors: no launch was attempted or queued. Report the prerequisite or continue independent work, and retry only after readiness changes. Do not poll, modify project code, or bypass readiness with `run_in_terminal`. For actual launch errors (mainClass missing, classpath unresolved, build failure with line number), use the reported details to suggest a fix. 5. **Stop when done.** When the user says "stop", "kill it", or has the answer they need, call `stop_debug_session`. ## Common Failure Modes | Symptom from `debug_java_application` | Likely cause | Suggested fix | |---|---|---| +| `JAVA_NOT_READY` | JDT LS has not reported ready, or Standard mode/project import has not started | Wait for Java initialization; in Lightweight/manual-import mode, switch to Standard mode or import the project before retrying | +| `NO_CONFIG_NOT_READY` | Java is ready but the terminal integration is still being prepared | Retry after preparation completes; do not repeat the call in a loop | +| `JAVA_INIT_FAILED` / `NO_CONFIG_INIT_FAILED` / `NO_CONFIG_DISABLED` | A startup prerequisite failed or the integration is disabled | Follow the returned initialization or setting guidance, not application-error recovery | | `mainClass is not configured` / `mainClass missing` | Project has no `launch.json`, and the file has no `public static void main` | Ask user which class to launch, or generate `launch.json` | | `Could not resolve classpath` | Maven/Gradle import has not completed, or `pom.xml` has unresolved dependencies | Wait for Java Language Server import, then ask user to run `Java: Clean Java Language Server Workspace` | | `Compilation failed` with file:line | Source code has a compile error | Fix the reported error in the source file, do not retry the launch | @@ -47,4 +50,4 @@ These language model tools are contributed by the `Debugger for Java` extension ## Fallback -If `debug_java_application` returns `Java Language Server not ready` or repeats the same error twice, fall back to `run_in_terminal` with the appropriate `mvn` or `gradle` command and report the raw output to the user. Do not retry the debug tool more than twice. +For an actual application launch error that repeats twice, fall back to `run_in_terminal` with the appropriate `mvn` or `gradle` command and report the raw output to the user. This fallback does not apply to readiness, initialization, disabled, cancelled, or disposed results. Do not retry the debug tool more than twice for the same application error. diff --git a/src/extension.ts b/src/extension.ts index 7696331c..f65e6054 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,7 +13,7 @@ import { ENABLE_NO_CONFIG_DEBUG, HCR_EVENT, JAVA_LANGID, TELEMETRY_EVENT, USER_N import { NotificationBar } from "./customWidget"; import { initializeCodeLensProvider, startDebugging } from "./debugCodeLensProvider"; import { initExpService } from "./experimentationService"; -import { registerNoConfigDebug } from "./noConfigDebugInit"; +import { NoConfigDebugRegistration, registerNoConfigDebug } from "./noConfigDebugInit"; import { handleHotCodeReplaceCustomEvent, initializeHotCodeReplace, NO_BUTTON, YES_BUTTON } from "./hotCodeReplace"; import { JavaDebugAdapterDescriptorFactory } from "./javaDebugAdapterDescriptorFactory"; import { JavaInlineValuesProvider } from "./JavaInlineValueProvider"; @@ -39,20 +39,18 @@ export async function activate(context: vscode.ExtensionContext): Promise { // 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( + const api = await instrumentOperation("activation", initializeExtension)(context); + const noConfigDebug = registerNoConfigDebug( context.environmentVariableCollection, context.extensionPath, context.storageUri, noConfigDebugEnabled, ); - if (noConfigDisposable) { - context.subscriptions.push(noConfigDisposable); - } + context.subscriptions.push(noConfigDebug); - // Register Language Model Tools after Java Language Server is ready - registerLanguageModelToolsWhenReady(context, noConfigDebugEnabled); + registerLanguageModelTools(context, noConfigDebug); - return instrumentOperation("activation", initializeExtension)(context); + return api; } function initializeExtension(_operationId: string, context: vscode.ExtensionContext): any { @@ -113,11 +111,10 @@ export async function deactivate() { 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, - * and executing debug server commands. + * Register tools when the Java extension is installed. The launch tool checks + * Java and No-Config readiness at invocation without waiting for initialization. */ -async function registerLanguageModelToolsWhenReady(context: vscode.ExtensionContext, noConfigDebugEnabled: boolean): Promise { +function registerLanguageModelTools(context: vscode.ExtensionContext, noConfigDebug: NoConfigDebugRegistration): void { // Check if Language Model API is available if (!vscode.lm || typeof vscode.lm.registerTool !== 'function') { return; @@ -129,7 +126,7 @@ async function registerLanguageModelToolsWhenReady(context: vscode.ExtensionCont } // Register Language Model Tools for AI-assisted debugging - registerLanguageModelTool(context, noConfigDebugEnabled); + registerLanguageModelTool(context, noConfigDebug); const debugToolsDisposables = registerDebugSessionTools(context); context.subscriptions.push(...debugToolsDisposables); diff --git a/src/javaServerReadiness.ts b/src/javaServerReadiness.ts new file mode 100644 index 00000000..9a06c9b8 --- /dev/null +++ b/src/javaServerReadiness.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as vscode from "vscode"; +import { sendError } from "vscode-extension-telemetry-wrapper"; +import { getJavaExtensionAPI } from "./utility"; + +export type JavaServerReadinessState = + | { status: "initializing" | "ready" | "disposed" } + | { status: "failed"; message: string }; + +export interface JavaServerReadiness extends vscode.Disposable { + getState(): JavaServerReadinessState; +} + +interface JavaServerAPI { + readonly status?: string; + readonly serverReady?: () => Thenable; +} + +const INITIALIZATION_FAILED = "Java language server initialization failed. " + + "Check the Java language server logs, resolve the startup problem, and reload VS Code before retrying."; + +export function observeJavaServerReadiness(): JavaServerReadiness { + let state: JavaServerReadinessState = { status: "initializing" }; + let javaApi: JavaServerAPI | undefined; + let disposed = false; + + function reportFailure(message: string): void { + if (disposed) { + return; + } + state = { status: "failed", message }; + // Activation errors may contain user paths; only report controlled messages. + sendError({ name: "JavaServerReadinessError", message }); + } + + async function initialize(): Promise { + const api: JavaServerAPI | undefined = await getJavaExtensionAPI(); + if (disposed) { + return; + } + if (!api || typeof api.serverReady !== "function") { + reportFailure("Java language server readiness API is unavailable. " + + "Update Language Support for Java by Red Hat and reload VS Code before retrying."); + return; + } + javaApi = api; + const ready = await api.serverReady(); + if (disposed) { + return; + } + if (!ready) { + reportFailure(INITIALIZATION_FAILED); + return; + } + state = { status: "ready" }; + } + + void initialize().catch(() => reportFailure(INITIALIZATION_FAILED)); + + return { + getState() { + // serverReady() is a success signal and need not reject on a server error. + if (!disposed && javaApi?.status === "Error") { + return { status: "failed", message: INITIALIZATION_FAILED }; + } + if (!disposed && javaApi?.status === "Stopping") { + return { status: "initializing" }; + } + return state; + }, + dispose() { + disposed = true; + state = { status: "disposed" }; + }, + }; +} diff --git a/src/languageModelTool.ts b/src/languageModelTool.ts index cdfa6dfb..29f848db 100644 --- a/src/languageModelTool.ts +++ b/src/languageModelTool.ts @@ -5,6 +5,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { ENABLE_NO_CONFIG_DEBUG } from "./constants"; +import { JavaServerReadiness, JavaServerReadinessState, observeJavaServerReadiness } from "./javaServerReadiness"; +import { NoConfigDebugRegistration, NoConfigDebugState } from "./noConfigDebugInit"; import { beginDebugSessionInvocation, classifyBreakpoint, @@ -109,13 +111,48 @@ interface LanguageModelTool { invoke(options: { input: T }, token: vscode.CancellationToken): Promise; } +function getLaunchReadinessMessage( + noConfig: NoConfigDebugState, + java: JavaServerReadinessState | undefined, +): string | undefined { + switch (noConfig.status) { + case "disabled": + return `NO_CONFIG_DISABLED: 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."; + case "failed": + return `NO_CONFIG_INIT_FAILED: ${noConfig.message} ` + + "Resolve the initialization problem and reload VS Code before retrying. " + + "Standard Java launch/attach debugging remains available."; + case "disposed": + return "NO_CONFIG_DISPOSED: Java No-Config Debug has been disposed. Reload VS Code before retrying this tool."; + } + + if (java?.status === "failed") { + return `JAVA_INIT_FAILED: ${java.message}`; + } + if (java?.status === "disposed") { + return "NO_CONFIG_DISPOSED: The Java debug launch tool has been disposed. Reload VS Code before retrying."; + } + if (java?.status !== "ready") { + return "JAVA_NOT_READY: JDT LS is not ready. Wait for Java initialization to complete before retrying. " + + "In Lightweight mode or with manual project import, switch to Standard mode or import the project first. " + + "Do not retry in a loop or change project code to resolve this readiness condition."; + } + if (noConfig.status === "initializing") { + return "NO_CONFIG_NOT_READY: Java No-Config Debug is still preparing its terminal environment. " + + "Retry after preparation completes; do not retry in a loop or change project code to resolve this readiness condition."; + } + return undefined; +} + /** * 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: Pick, - noConfigDebugEnabled: boolean = true, + noConfigDebug: Pick, ): vscode.Disposable | undefined { // Check if the Language Model API is available const lmApi = (vscode as any).lm; @@ -124,14 +161,22 @@ export function registerLanguageModelTool( return undefined; } + let javaReadiness: JavaServerReadiness | undefined; + let disposed = false; const tool: LanguageModelTool = { async invoke(options: { input: DebugJavaApplicationInput }, token: vscode.CancellationToken): Promise { - if (!noConfigDebugEnabled) { + let readinessMessage: string | undefined; + if (token.isCancellationRequested) { + readinessMessage = "CANCELLED: Operation cancelled by user."; + } else if (disposed) { + readinessMessage = "NO_CONFIG_DISPOSED: The Java debug launch tool has been disposed. Reload VS Code before retrying."; + } else { + readinessMessage = getLaunchReadinessMessage(noConfigDebug.getState(), javaReadiness?.getState()); + } + if (readinessMessage) { 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.", + `${readinessMessage} No build, terminal, or debug session changes were made.`, ), ]); } @@ -209,7 +254,16 @@ export function registerLanguageModelTool( } }; - const disposable = lmApi.registerTool('debug_java_application', tool); + const registration = lmApi.registerTool('debug_java_application', tool); + const noConfigState = noConfigDebug.getState(); + if (noConfigState.status === "initializing" || noConfigState.status === "ready") { + javaReadiness = observeJavaServerReadiness(); + } + const disposable = new vscode.Disposable(() => { + disposed = true; + javaReadiness?.dispose(); + registration.dispose(); + }); context.subscriptions.push(disposable); return disposable; } diff --git a/src/noConfigDebugInit.ts b/src/noConfigDebugInit.ts index f51a2c44..89cd4cbe 100644 --- a/src/noConfigDebugInit.ts +++ b/src/noConfigDebugInit.ts @@ -12,6 +12,23 @@ import { applyAppendIfChanged, applyReplaceIfChanged } from "./envVarSync"; const ENV_VAR_COLLECTION_DESCRIPTION = "Java No-Config Debug"; +export type NoConfigDebugResult = + | { status: "ready" | "disabled" | "disposed" } + | { status: "failed"; message: string }; + +export type NoConfigDebugState = NoConfigDebugResult | { status: "initializing" }; + +export interface NoConfigDebugRegistration extends vscode.Disposable { + // Terminal preparation is independent of JDT LS server readiness. + readonly ready: Promise; + getState(): NoConfigDebugState; +} + +interface InitializationLifetime { + token: vscode.CancellationToken; + disposables: vscode.Disposable[]; +} + function clearNoConfigDebugEnvironment(collection: vscode.EnvironmentVariableCollection): void { for (const variable of ["VSCODE_JDWP_ADAPTER_ENDPOINTS", "VSCODE_JAVA_EXEC", "PATH"]) { if (collection.get(variable)) { @@ -33,18 +50,20 @@ function clearNoConfigDebugEnvironment(collection: vscode.EnvironmentVariableCol * * @param scriptPath - The installed debugjava wrapper path. * @param platform - The current operating system platform. + * @param token - Stops further permission work when initialization is disposed. */ export async function ensureDebugJavaScriptExecutable( scriptPath: string, platform: NodeJS.Platform = process.platform, + token?: vscode.CancellationToken, ): Promise { - if (platform === "win32") { + if (platform === "win32" || token?.isCancellationRequested) { return; } const permissions = (await fs.promises.stat(scriptPath)).mode % 0o10000; const ownerPermissions = Math.floor(permissions / 0o100); - if (ownerPermissions % 2 === 0) { + if (ownerPermissions % 2 === 0 && !token?.isCancellationRequested) { await fs.promises.chmod(scriptPath, permissions + 0o100); } } @@ -59,24 +78,91 @@ export async function ensureDebugJavaScriptExecutable( * @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. + * @returns An immediately disposable registration with a shared initialization result. * * Environment Variables: * - `VSCODE_JDWP_ADAPTER_ENDPOINTS`: Path to the file containing the debugger adapter endpoint. * - `VSCODE_JAVA_EXEC`: Path to the java executable from the Java Language Server (when available). * - `PATH`: Appends the path to the noConfigScripts directory. */ -export async function registerNoConfigDebug( +export function registerNoConfigDebug( envVarCollection: vscode.EnvironmentVariableCollection, extPath: string, storageUri: vscode.Uri | undefined, enabled: boolean = true, -): Promise { +): NoConfigDebugRegistration { + const cancellation = new vscode.CancellationTokenSource(); + const lifetime: InitializationLifetime = { token: cancellation.token, disposables: [] }; + let state: NoConfigDebugState = { status: enabled ? "initializing" : "disabled" }; + let resolveReady!: (result: NoConfigDebugResult) => void; + const ready = new Promise((resolve) => { resolveReady = resolve; }); + const complete = (result: NoConfigDebugResult) => { + state = result; + resolveReady(result); + }; + const releaseResources = () => { + for (const disposable of lifetime.disposables.splice(0).reverse()) { + disposable.dispose(); + } + }; + + // Handle the background task here so activation and AI callers never inherit a rejection. + void initializeNoConfigDebug(envVarCollection, extPath, storageUri, enabled, lifetime).then( + (result) => { + if (!lifetime.token.isCancellationRequested) { + if (result.status !== "ready") { + releaseResources(); + } + complete(result); + } + }, + (error: unknown) => { + if (lifetime.token.isCancellationRequested) { + return; + } + releaseResources(); + clearNoConfigDebugEnvironment(envVarCollection); + complete(reportInitializationFailure(error)); + }, + ); + + return { + ready, + getState: () => state, + dispose() { + if (lifetime.token.isCancellationRequested) { + return; + } + cancellation.cancel(); + cancellation.dispose(); + releaseResources(); + complete({ status: "disposed" }); + }, + }; +} + +function reportInitializationFailure(error: unknown): NoConfigDebugResult { + // 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"; + const message = `Java No-Config Debug initialization failed (${code}).`; + sendError({ name: "NoConfigDebugError", message: `[Java Debug] No-config debug initialization failed (${code}).` }); + vscode.window.showWarningMessage(`${message} Standard Java debugging is still available.`); + return { status: "failed", message }; +} + +async function initializeNoConfigDebug( + envVarCollection: vscode.EnvironmentVariableCollection, + extPath: string, + storageUri: vscode.Uri | undefined, + enabled: boolean, + lifetime: InitializationLifetime, +): Promise { const collection = envVarCollection; + const { token, disposables } = lifetime; if (!enabled) { clearNoConfigDebugEnvironment(collection); - return undefined; + return { status: "disabled" }; } if (!storageUri) { @@ -86,7 +172,7 @@ export async function registerNoConfigDebug( message: '[Java Debug] No workspace folder found', }; sendError(error); - return undefined; + return { status: "failed", message: "No workspace folder found for Java No-Config Debug." }; } // Workspace storage is stable across reloads and does not require a writable @@ -97,31 +183,33 @@ export async function registerNoConfigDebug( try { await fs.promises.mkdir(tempDirPath, { recursive: true, mode: 0o700 }); + if (token.isCancellationRequested) { + return { status: "disposed" }; + } // 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; } }); + if (token.isCancellationRequested) { + return { status: "disposed" }; + } fileSystemWatcher = vscode.workspace.createFileSystemWatcher( new vscode.RelativePattern(tempDirPath, path.basename(tempFilePath)), ); + disposables.push(fileSystemWatcher); } catch (error: unknown) { + if (token.isCancellationRequested) { + return { status: "disposed" }; + } 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] 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; + return reportInitializationFailure(error); } // Track active debug sessions to prevent duplicates const activeDebugSessions = new Set(); + disposables.push(new vscode.Disposable(() => activeDebugSessions.clear())); // Handle both file creation and modification to support multiple runs const handleEndpointFile = async (uri: vscode.Uri) => { @@ -130,8 +218,14 @@ export async function registerNoConfigDebug( // Add a small delay to ensure file is fully written // File system events can fire before write is complete await new Promise(resolve => setTimeout(resolve, 100)); + if (token.isCancellationRequested) { + return; + } fs.readFile(filePath, (err, data) => { + if (token.isCancellationRequested) { + return; + } if (err) { const error: Error = { name: "NoConfigDebugError", @@ -193,12 +287,18 @@ export async function registerNoConfigDebug( options, ).then( (started) => { + if (token.isCancellationRequested) { + return; + } if (started) { // Send telemetry only on successful session start with port info sendInfo('', { message: '[Java Debug] No-config debug session started', port: clientPort }); // Clean up the endpoint file after successful debug session start (async) if (fs.existsSync(filePath)) { fs.promises.unlink(filePath).catch((cleanupErr) => { + if (token.isCancellationRequested) { + return; + } // Cleanup failure is non-critical, just log for debugging const error: Error = { name: "NoConfigDebugError", @@ -218,6 +318,9 @@ export async function registerNoConfigDebug( } }, (error) => { + if (token.isCancellationRequested) { + return; + } const attachError: Error = { name: "NoConfigDebugError", message: `[Java Debug] No-config debug failed: attach_error - port ${clientPort} - ${error}`, @@ -239,17 +342,17 @@ export async function registerNoConfigDebug( // 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); + disposables.push(fileSystemWatcher.onDidCreate(handleEndpointFile)); + disposables.push(fileSystemWatcher.onDidChange(handleEndpointFile)); // Clean up active sessions when debug session ends - const debugSessionEndListener = vscode.debug.onDidTerminateDebugSession((session) => { + disposables.push(vscode.debug.onDidTerminateDebugSession((session) => { if (session.name === 'Attach to Java (No-Config)' && session.configuration.port) { const port = session.configuration.port; activeDebugSessions.delete(port); // Session end is normal operation, no telemetry needed } - }); + })); // Surface a description in VS Code's environment variable UI so users can // see which extension is contributing these variables. @@ -273,6 +376,9 @@ export async function registerNoConfigDebug( // set VSCODE_JAVA_EXEC to avoid churn from transient startup failures. try { const javaHome = await getJavaHome(); + if (token.isCancellationRequested) { + return { status: "disposed" }; + } if (javaHome) { const javaExec = path.join(javaHome, 'bin', 'java'); applyReplaceIfChanged(collection, 'VSCODE_JAVA_EXEC', javaExec); @@ -282,26 +388,27 @@ export async function registerNoConfigDebug( // The wrapper script will fall back to JAVA_HOME or PATH } + if (token.isCancellationRequested) { + return { status: "disposed" }; + } const noConfigScriptsDir = path.join(extPath, 'bundled', 'scripts', 'noConfigScripts'); const debugJavaScriptPath = path.join(noConfigScriptsDir, "debugjava"); try { - await ensureDebugJavaScriptExecutable(debugJavaScriptPath); + await ensureDebugJavaScriptExecutable(debugJavaScriptPath, process.platform, token); } catch (err) { + if (token.isCancellationRequested) { + return { status: "disposed" }; + } const error: Error = { name: "NoConfigDebugError", message: `[Java Debug] Failed to make debugjava executable: ${err}`, }; sendError(error); } + if (token.isCancellationRequested) { + return { status: "disposed" }; + } applyAppendIfChanged(collection, 'PATH', buildNoConfigPathAppendValue(noConfigScriptsDir)); - return Promise.resolve( - new vscode.Disposable(() => { - fileSystemWatcher.dispose(); - fileCreationEvent.dispose(); - fileChangeEvent.dispose(); - debugSessionEndListener.dispose(); - activeDebugSessions.clear(); - }), - ); + return { status: "ready" }; } diff --git a/src/utility.ts b/src/utility.ts index 94c8e77c..06fe6208 100644 --- a/src/utility.ts +++ b/src/utility.ts @@ -177,13 +177,23 @@ 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 { diff --git a/test/helpers/deferred.ts b/test/helpers/deferred.ts new file mode 100644 index 00000000..17d4fa5a --- /dev/null +++ b/test/helpers/deferred.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +export function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +export async function withinDeadline(promise: Promise, message = "Operation did not settle", timeoutMs = 500): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} diff --git a/test/javaExtensionAPI.test.ts b/test/javaExtensionAPI.test.ts new file mode 100644 index 00000000..fbaea7c3 --- /dev/null +++ b/test/javaExtensionAPI.test.ts @@ -0,0 +1,140 @@ +// 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"; +import { deferred, withinDeadline } from "./helpers/deferred"; + +suite("Java extension API activation", () => { + const api = { javaRequirement: { java_home: path.join("test-jdk") } }; + let activation: ReturnType>; + let activateCalls: number; + let source: vscode.CancellationTokenSource; + let listenerCount: number; + let progress: IProgressReporter; + let restoreExtension: () => void; + + setup(() => { + activation = deferred(); + 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.promise; + }, + }; + 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 () => { + activation.resolve(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); + activation.reject(error); + await withinDeadline(rejected); + assert.strictEqual(activateCalls, 1); + }); + + test("reads the Java tooling home from the activated API", async () => { + const home = getJavaHome(); + activation.resolve(api); + assert.strictEqual(await withinDeadline(home), api.javaRequirement.java_home); + }); + + test("removes the cancellation listener after successful activation", async () => { + const result = Promise.resolve(getJavaExtensionAPI(progress)); + assert.strictEqual(listenerCount, 1); + activation.resolve(api); + assert.strictEqual(await withinDeadline(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); + activation.reject(error); + await withinDeadline(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 withinDeadline(cancelled), undefined); + assert.strictEqual(otherSettled, false); + assert.strictEqual(listenerCount, 0); + + activation.resolve(api); + assert.strictEqual(await withinDeadline(other), api); + }); + + test("handles a late activation rejection after caller cancellation", async () => { + const cancelled = Promise.resolve(getJavaExtensionAPI(progress)); + source.cancel(); + assert.strictEqual(await withinDeadline(cancelled), undefined); + activation.reject(new Error("Late activation failure")); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(listenerCount, 0); + }); +}); diff --git a/test/javaServerReadiness.test.ts b/test/javaServerReadiness.test.ts new file mode 100644 index 00000000..afa12973 --- /dev/null +++ b/test/javaServerReadiness.test.ts @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from "assert"; +import * as telemetry from "vscode-extension-telemetry-wrapper"; + +import { JavaServerReadiness, observeJavaServerReadiness } from "../src/javaServerReadiness"; +import * as utility from "../src/utility"; +import { deferred } from "./helpers/deferred"; + +suite("Java language server readiness observer", () => { + const initializationFailed = "Java language server initialization failed. " + + "Check the Java language server logs, resolve the startup problem, and reload VS Code before retrying."; + const apiUnavailable = "Java language server readiness API is unavailable. " + + "Update Language Support for Java by Red Hat and reload VS Code before retrying."; + const privateFailure = "Private Java startup details at C:\\private\\workspace\\Main.java"; + + interface TestJavaAPI { + serverMode: utility.ServerMode; + status: string; + serverReady?: () => Thenable; + } + + let cleanups: (() => void)[]; + let observers: JavaServerReadiness[]; + let activation: ReturnType>; + let serverReady: ReturnType>; + let javaApi: TestJavaAPI; + let apiCalls: number; + let serverReadyCalls: number; + let errorTelemetry: Parameters[]; + + 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)); + } + + function nextTurn(): Promise { + return new Promise((resolve) => setImmediate(resolve)); + } + + function observe(): JavaServerReadiness { + const observer = observeJavaServerReadiness(); + observers.push(observer); + assert.deepStrictEqual(observer.getState(), { status: "initializing" }); + return observer; + } + + function assertReportedFailure(observer: JavaServerReadiness, message: string = initializationFailed): void { + assert.deepStrictEqual(observer.getState(), { status: "failed", message }); + assert.deepStrictEqual(observer.getState(), { status: "failed", message }); + assert.deepStrictEqual(errorTelemetry, [[{ name: "JavaServerReadinessError", message }]]); + } + + setup(() => { + cleanups = []; + observers = []; + activation = deferred(); + serverReady = deferred(); + apiCalls = 0; + serverReadyCalls = 0; + errorTelemetry = []; + javaApi = { + serverMode: utility.ServerMode.STANDARD, + status: "Started", + serverReady() { + serverReadyCalls += 1; + return serverReady.promise; + }, + }; + overrideProperty(utility, "getJavaExtensionAPI", { + value: () => { + apiCalls += 1; + return activation.promise; + }, + }); + overrideProperty(telemetry, "sendError", { + value: (...args: Parameters) => { errorTelemetry.push(args); }, + }); + }); + + teardown(async () => { + for (const observer of observers.reverse()) { + observer.dispose(); + } + activation.resolve(undefined); + serverReady.resolve(true); + await nextTurn(); + for (const cleanup of cleanups.reverse()) { + cleanup(); + } + }); + + test("starts activation once and stays initializing while the Java API is pending", async () => { + const observer = observe(); + assert.deepStrictEqual(observer.getState(), { status: "initializing" }); + await nextTurn(); + + assert.deepStrictEqual(observer.getState(), { status: "initializing" }); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, 0); + assert.strictEqual(errorTelemetry.length, 0); + }); + + test("waits for serverReady even when the API reports Standard mode and Started status", async () => { + const observer = observe(); + activation.resolve(javaApi); + await nextTurn(); + + assert.deepStrictEqual(observer.getState(), { status: "initializing" }); + assert.deepStrictEqual(observer.getState(), { status: "initializing" }); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, 1); + assert.strictEqual(errorTelemetry.length, 0); + }); + + test("becomes ready after serverReady resolves true without restarting observation on state reads", async () => { + const observer = observe(); + activation.resolve(javaApi); + await nextTurn(); + serverReady.resolve(true); + await nextTurn(); + + assert.deepStrictEqual(observer.getState(), { status: "ready" }); + assert.deepStrictEqual(observer.getState(), { status: "ready" }); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, 1); + assert.strictEqual(errorTelemetry.length, 0); + }); + + test("handles activation rejection with controlled state and error telemetry, not raw error details", async () => { + const observer = observe(); + activation.reject(new Error(privateFailure)); + await nextTurn(); + + assertReportedFailure(observer); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, 0); + }); + + test("handles a synchronous Java API lookup error as an initialization failure", async () => { + overrideProperty(utility, "getJavaExtensionAPI", { + value: () => { + apiCalls += 1; + throw new Error(privateFailure); + }, + }); + const observer = observe(); + await nextTurn(); + + assertReportedFailure(observer); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, 0); + }); + + for (const outcome of ["rejection", "false"]) { + test(`reports controlled initialization failure when serverReady returns ${outcome}`, async () => { + const observer = observe(); + activation.resolve(javaApi); + await nextTurn(); + if (outcome === "rejection") { + serverReady.reject(new Error(privateFailure)); + } else { + serverReady.resolve(false); + } + await nextTurn(); + + assertReportedFailure(observer); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, 1); + }); + } + + for (const missing of ["API", "serverReady"]) { + test(`reports update guidance when the Java ${missing} is missing`, async () => { + const observer = observe(); + activation.resolve(missing === "API" ? undefined : { + serverMode: utility.ServerMode.STANDARD, + status: "Started", + }); + await nextTurn(); + + assertReportedFailure(observer, apiUnavailable); + assert.strictEqual(serverReadyCalls, 0); + }); + } + + for (const ready of [false, true]) { + test(`reports Error status even when serverReady is ${ready ? "resolved" : "pending"}`, async () => { + const observer = observe(); + activation.resolve(javaApi); + if (ready) { + serverReady.resolve(true); + } + await nextTurn(); + assert.deepStrictEqual(observer.getState(), { status: ready ? "ready" : "initializing" }); + javaApi.status = "Error"; + + assert.deepStrictEqual(observer.getState(), { status: "failed", message: initializationFailed }); + assert.deepStrictEqual(observer.getState(), { status: "failed", message: initializationFailed }); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, 1); + assert.strictEqual(errorTelemetry.length, 0); + }); + } + + test("reports initializing while the server is Stopping even after successful readiness", async () => { + const observer = observe(); + activation.resolve(javaApi); + serverReady.resolve(true); + await nextTurn(); + assert.deepStrictEqual(observer.getState(), { status: "ready" }); + javaApi.status = "Stopping"; + + assert.deepStrictEqual(observer.getState(), { status: "initializing" }); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, 1); + assert.strictEqual(errorTelemetry.length, 0); + }); + + for (const phase of ["activation", "serverReady"]) { + for (const outcome of ["success", "rejection"]) { + test(`preserves disposal on late ${phase} ${outcome} without further work or error telemetry`, async () => { + const observer = observe(); + if (phase === "serverReady") { + activation.resolve(javaApi); + await nextTurn(); + assert.strictEqual(serverReadyCalls, 1); + } + observer.dispose(); + observer.dispose(); + javaApi.status = "Error"; + assert.deepStrictEqual(observer.getState(), { status: "disposed" }); + + if (outcome === "rejection") { + const pending = phase === "activation" ? activation : serverReady; + pending.reject(new Error(privateFailure)); + } else if (phase === "activation") { + activation.resolve(javaApi); + } else { + serverReady.resolve(true); + } + await nextTurn(); + + assert.deepStrictEqual(observer.getState(), { status: "disposed" }); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, phase === "activation" ? 0 : 1); + assert.strictEqual(errorTelemetry.length, 0); + }); + } + } +}); diff --git a/test/noConfigDebugActivation.test.ts b/test/noConfigDebugActivation.test.ts new file mode 100644 index 00000000..bc7b4c50 --- /dev/null +++ b/test/noConfigDebugActivation.test.ts @@ -0,0 +1,224 @@ +// 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 * as telemetry from "vscode-extension-telemetry-wrapper"; + +import { ENABLE_NO_CONFIG_DEBUG } from "../src/constants"; +import * as experimentation from "../src/experimentationService"; +import { activate } from "../src/extension"; +import * as languageModelTools from "../src/languageModelTool"; +import * as chatTelemetry from "../src/lmToolTelemetry"; +import * as noConfigDebug from "../src/noConfigDebugInit"; +import { deferred, withinDeadline } from "./helpers/deferred"; +import { createFakeCollection } from "./helpers/environmentVariableCollection"; + +suite("No-Config Debug activation", () => { + const restores: (() => void)[] = []; + + function stub(target: object, key: string, value: unknown): void { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + assert.ok(descriptor, `Expected an existing export: ${key}`); + Object.defineProperty(target, key, { ...descriptor, value }); + restores.push(() => Object.defineProperty(target, key, descriptor!)); + } + + teardown(() => { + for (const restore of restores.splice(0).reverse()) { + restore(); + } + }); + + const cases: { name: string; enabled: boolean; status: "ready" | "disposed" | "disabled" }[] = [ + { name: "returns the core API before optional startup completes", enabled: true, status: "ready" }, + { name: "owns the pending registration before disposal", enabled: true, status: "disposed" }, + { name: "forwards the disabled snapshot and still registers the other AI tools", enabled: false, status: "disabled" }, + ]; + + for (const testCase of cases) { + test(testCase.name, async () => { + const events: string[] = []; + const coreStarted = deferred(); + const coreFinished = deferred(); + const startup = deferred(); + const context = createContext(); + const api = { progressProvider: {} }; + const coreDisposable = new vscode.Disposable(() => events.push("core:dispose")); + const launchDisposable = new vscode.Disposable(() => events.push("launch-tool:dispose")); + const debugDisposable = new vscode.Disposable(() => events.push("debug-tools:dispose")); + let readySettled = false; + let disposed = false; + const registration: noConfigDebug.NoConfigDebugRegistration = { + ready: startup.promise.then((result) => { + readySettled = true; + events.push(`no-config:${result.status}`); + return result; + }), + getState() { + return { status: testCase.enabled ? "initializing" : "disabled" }; + }, + dispose() { + if (!disposed) { + disposed = true; + events.push("no-config:dispose"); + startup.resolve({ status: "disposed" }); + } + }, + }; + + stub(telemetry, "initializeFromJsonFile", async (packagePath: string) => { + assert.strictEqual(packagePath, path.join(context.extensionPath, "package.json")); + events.push("telemetry"); + }); + stub(experimentation, "initExpService", async (actualContext: vscode.ExtensionContext) => { + assert.strictEqual(actualContext, context); + events.push("experimentation"); + }); + stub(vscode.workspace, "getConfiguration", () => ({ + get(setting: string, defaultValue: boolean) { + assert.strictEqual(setting, ENABLE_NO_CONFIG_DEBUG); + assert.strictEqual(defaultValue, true); + events.push("snapshot"); + return testCase.enabled; + }, + })); + stub(telemetry, "instrumentOperation", (name: string, initialize: (id: string, ctx: vscode.ExtensionContext) => unknown) => { + assert.strictEqual(name, "activation"); + assert.strictEqual(initialize.name, "initializeExtension"); + events.push("instrument:activation"); + return async (actualContext: vscode.ExtensionContext) => { + assert.strictEqual(actualContext, context); + events.push("core:start"); + coreStarted.resolve(); + await coreFinished.promise; + context.subscriptions.push(coreDisposable); + events.push("core:complete"); + return api; + }; + }); + stub(noConfigDebug, "registerNoConfigDebug", ( + collection: vscode.EnvironmentVariableCollection, + extensionPath: string, + storageUri: vscode.Uri | undefined, + enabled: boolean, + ) => { + assert.strictEqual(collection, context.environmentVariableCollection); + assert.strictEqual(extensionPath, context.extensionPath); + assert.strictEqual(storageUri, context.storageUri); + assert.strictEqual(enabled, testCase.enabled); + assert.deepStrictEqual(context.subscriptions, [coreDisposable]); + events.push("no-config:start"); + return registration; + }); + stub(vscode.extensions, "getExtension", (id: string) => { + assert.strictEqual(id, "redhat.java"); + events.push("java:lookup"); + return createExtension(id, context.extensionPath); + }); + stub(vscode.lm, "registerTool", () => { + assert.fail("The activation test must not register real language model tools"); + }); + stub(languageModelTools, "registerLanguageModelTool", ( + actualContext: vscode.ExtensionContext, + actualRegistration: noConfigDebug.NoConfigDebugRegistration, + ) => { + assert.strictEqual(actualContext, context); + assert.strictEqual(actualRegistration, registration); + assert.deepStrictEqual(context.subscriptions, [coreDisposable, registration]); + assert.strictEqual(readySettled, false); + events.push("launch-tool:register"); + context.subscriptions.push(launchDisposable); + return launchDisposable; + }); + stub(languageModelTools, "registerDebugSessionTools", (actualContext: vscode.ExtensionContext) => { + assert.strictEqual(actualContext, context); + assert.strictEqual(readySettled, false); + events.push("debug-tools:register"); + return [debugDisposable]; + }); + stub(chatTelemetry, "recordChatActivation", () => events.push("chat:telemetry")); + + const activation = activate(context); + try { + await withinDeadline(coreStarted.promise, "Core initialization did not start"); + assert.deepStrictEqual(events, [ + "telemetry", "experimentation", "snapshot", "instrument:activation", "core:start", + ]); + assert.strictEqual(context.subscriptions.length, 0); + coreFinished.resolve(); + + assert.strictEqual(await withinDeadline(activation, "Activation waited for optional startup"), api); + assert.strictEqual(readySettled, false, "No-Config readiness must still be genuinely pending"); + assert.strictEqual(disposed, false); + assert.deepStrictEqual(events, [ + "telemetry", "experimentation", "snapshot", "instrument:activation", "core:start", + "core:complete", "no-config:start", "java:lookup", "launch-tool:register", + "debug-tools:register", "chat:telemetry", + ]); + assert.deepStrictEqual(context.subscriptions, [coreDisposable, registration, launchDisposable, debugDisposable]); + + if (testCase.status === "disposed") { + for (const disposable of context.subscriptions.splice(0)) { + disposable.dispose(); + } + assert.strictEqual(disposed, true); + } else { + startup.resolve({ status: testCase.status }); + } + assert.deepStrictEqual(await withinDeadline(registration.ready, "Startup did not settle"), { status: testCase.status }); + assert.strictEqual(readySettled, true); + } finally { + // Release both gates even when a regression makes activation await optional startup. + coreFinished.resolve(); + startup.resolve({ status: "disposed" }); + try { + await withinDeadline(activation, "Activation did not settle during cleanup"); + } finally { + for (const disposable of context.subscriptions.splice(0).reverse()) { + disposable.dispose(); + } + registration.dispose(); + } + } + }); + } +}); + +function createExtension(id: string, extensionPath: string): vscode.Extension { + return { + id, + extensionPath, + extensionUri: vscode.Uri.file(extensionPath), + isActive: false, + packageJSON: { version: "test" }, + extensionKind: vscode.ExtensionKind.Workspace, + get exports(): never { throw new Error("Unexpected extension exports access"); }, + activate(): never { throw new Error("The test must not activate a real Java extension"); }, + }; +} + +function createContext(): vscode.ExtensionContext { + const extensionPath = path.resolve(__dirname, "../.."); + const collection = createFakeCollection(); + return { + subscriptions: [], + extensionPath, + extensionUri: vscode.Uri.file(extensionPath), + storageUri: vscode.Uri.file(path.join(extensionPath, ".activation-test-storage")), + environmentVariableCollection: { ...collection, getScoped: () => collection }, + extensionMode: vscode.ExtensionMode.Test, + extension: createExtension("vscjava.vscode-java-debug", extensionPath), + 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"); }, + }; +} diff --git a/test/noConfigDebugInit.test.ts b/test/noConfigDebugInit.test.ts index 6d255f94..865f4961 100644 --- a/test/noConfigDebugInit.test.ts +++ b/test/noConfigDebugInit.test.ts @@ -5,8 +5,10 @@ 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 { ensureDebugJavaScriptExecutable } from "../src/noConfigDebugInit"; +import { deferred } from "./helpers/deferred"; suite("No-Config Debug scripts", () => { test("the bundled POSIX wrapper uses LF and is executable", async () => { @@ -103,4 +105,28 @@ suite("No-Config Debug scripts", () => { await ensureDebugJavaScriptExecutable(missingScriptPath, "win32"); }); + + test("does not chmod after disposal while stat is pending", async () => { + const originalStat = fs.promises.stat; + const statDescriptor = Object.getOwnPropertyDescriptor(fs.promises, "stat")!; + const originalChmod = fs.promises.chmod; + const cancelled = new vscode.CancellationTokenSource(); + const permissions = deferred(); + let chmodCalls = 0; + try { + Object.defineProperty(fs.promises, "stat", { ...statDescriptor, value: () => permissions.promise }); + fs.promises.chmod = async () => { chmodCalls += 1; }; + const pending = ensureDebugJavaScriptExecutable("debugjava", "linux", cancelled.token); + cancelled.cancel(); + const stat = await originalStat(__filename); + stat.mode = 0o644; + permissions.resolve(stat); + await pending; + assert.strictEqual(chmodCalls, 0); + } finally { + Object.defineProperty(fs.promises, "stat", statDescriptor); + fs.promises.chmod = originalChmod; + cancelled.dispose(); + } + }); }); diff --git a/test/noConfigDebugSettings.test.ts b/test/noConfigDebugSettings.test.ts index 52f440dd..9876e4ab 100644 --- a/test/noConfigDebugSettings.test.ts +++ b/test/noConfigDebugSettings.test.ts @@ -8,7 +8,11 @@ import * as vscode from "vscode"; import * as telemetry from "vscode-extension-telemetry-wrapper"; import { ENABLE_NO_CONFIG_DEBUG } from "../src/constants"; +import * as javaServerReadiness from "../src/javaServerReadiness"; import { registerLanguageModelTool } from "../src/languageModelTool"; +import { NoConfigDebugRegistration, NoConfigDebugState } from "../src/noConfigDebugInit"; +import * as utility from "../src/utility"; +import { deferred, withinDeadline } from "./helpers/deferred"; suite("No-Config Debug setting", () => { test("is a default-enabled window-scoped setting with localized descriptions", async () => { @@ -28,12 +32,75 @@ suite("No-Config Debug setting", () => { assert.ok(translations[descriptionKey].includes("debugjava"), file); } }); + + test("documents fail-fast readiness without changing Java launch tool visibility", async () => { + const repoRoot = path.resolve(__dirname, "../.."); + const manifest = JSON.parse(await fs.promises.readFile(path.join(repoRoot, "package.json"), "utf8")); + const tool = manifest.contributes.languageModelTools.find((candidate: { name: string }) => candidate.name === "debug_java_application"); + assert.ok(tool); + assert.strictEqual(tool.when, "javaLSReady"); + assert.strictEqual(typeof tool.modelDescription, "string"); + for (const code of ["JAVA_NOT_READY", "NO_CONFIG_NOT_READY"]) { + assert.ok(tool.modelDescription.includes(code), code); + } + }); }); -suite("No-Config Debug AI opt-out", () => { +suite("No-Config Debug AI startup readiness", () => { + const noChanges = " No build, terminal, or debug session changes were made."; + const javaNotReady = "JAVA_NOT_READY: JDT LS is not ready. Wait for Java initialization to complete before retrying. " + + "In Lightweight mode or with manual project import, switch to Standard mode or import the project first. " + + "Do not retry in a loop or change project code to resolve this readiness condition."; + const javaInitializationFailed = "JAVA_INIT_FAILED: Java language server initialization failed. " + + "Check the Java language server logs, resolve the startup problem, and reload VS Code before retrying."; + const toolDisposed = "NO_CONFIG_DISPOSED: The Java debug launch tool has been disposed. Reload VS Code before retrying."; + const terminalStates: { state: NoConfigDebugState; message: string }[] = [ + { + state: { status: "disabled" }, + message: `NO_CONFIG_DISABLED: 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.", + }, + { + state: { status: "failed", message: "Java No-Config Debug initialization failed (EACCES)." }, + message: "NO_CONFIG_INIT_FAILED: Java No-Config Debug initialization failed (EACCES). " + + "Resolve the initialization problem and reload VS Code before retrying. " + + "Standard Java launch/attach debugging remains available.", + }, + { + state: { status: "disposed" }, + message: "NO_CONFIG_DISPOSED: Java No-Config Debug has been disposed. Reload VS Code before retrying this tool.", + }, + ]; + + interface TestJavaAPI { + serverMode: utility.ServerMode; + status: string; + serverReady?: () => Thenable; + } + let registeredTool: vscode.LanguageModelTool | undefined; let registeredName: string | undefined; + let lmRegistration: vscode.Disposable | undefined; + let registrations: vscode.Disposable[]; let cleanups: (() => void)[]; + let cancellation: vscode.CancellationTokenSource; + let activation: ReturnType>; + let serverReady: ReturnType>; + let javaApi: TestJavaAPI; + let noConfigState: NoConfigDebugState; + let apiCalls: number; + let serverReadyCalls: number; + let observerStarts: number; + let observerDisposals: number; + let lmDisposals: number; + let noConfigStateReads: number; + let javaStateReads: number; + let inputReads: number; + let targetReads: number; + let javaVersionProbes: number; + let launchTelemetry: Parameters[]; + let errorTelemetry: Parameters[]; let sideEffects: number; function overrideProperty(target: object, key: string, descriptor: PropertyDescriptor): void { @@ -43,89 +110,374 @@ suite("No-Config Debug AI opt-out", () => { cleanups.push(() => Object.defineProperty(target, key, original)); } + function nextTurn(): Promise { + return new Promise((resolve) => setImmediate(resolve)); + } + + function registerReadiness(state: NoConfigDebugState): { + tool: vscode.LanguageModelTool; + disposable: vscode.Disposable; + } { + noConfigState = state; + const readiness: Pick = { + getState() { + noConfigStateReads += 1; + return noConfigState; + }, + }; + const context: Pick = { subscriptions: [] }; + const disposable = registerLanguageModelTool(context, readiness); + assert.ok(disposable); + registrations.push(disposable); + assert.strictEqual(context.subscriptions.length, 1); + assert.strictEqual(context.subscriptions[0], disposable); + assert.notStrictEqual(disposable, lmRegistration); + assert.strictEqual(registeredName, "debug_java_application"); + assert.ok(registeredTool); + return { tool: registeredTool, disposable }; + } + + function resultText(result: unknown): string { + assert.ok(result instanceof vscode.LanguageModelToolResult); + assert.strictEqual(result.content.length, 1); + const text = result.content[0]; + assert.ok(text instanceof vscode.LanguageModelTextPart); + return text.value; + } + + function assertNoLaunchWork(): void { + assert.strictEqual(inputReads, 0); + assert.strictEqual(targetReads, 0); + assert.strictEqual(javaVersionProbes, 0); + assert.strictEqual(launchTelemetry.length, 0); + assert.strictEqual(sideEffects, 0); + } + + async function invokePromptly( + tool: vscode.LanguageModelTool, + options: vscode.LanguageModelToolInvocationOptions, + ): Promise { + const startedAt = Date.now(); + const result = await withinDeadline( + Promise.resolve(tool.invoke(options, cancellation.token)), + "The invocation did not complete within 500 ms", + ); + assert.ok(Date.now() - startedAt < 500, "The invocation must complete within 500 ms"); + return resultText(result); + } + + async function invokeBlockedTool(tool: vscode.LanguageModelTool): Promise { + const errorsBefore = errorTelemetry.slice(); + const text = await invokePromptly(tool, { + get input(): never { + inputReads += 1; + throw new Error("The launch tool must not inspect inputs before initialization is ready"); + }, + toolInvocationToken: undefined, + }); + assertNoLaunchWork(); + assert.deepStrictEqual(errorTelemetry, errorsBefore); + return text; + } + + async function invokeAndCancelAfterReadiness(tool: vscode.LanguageModelTool): Promise { + const input = { + get target(): string { + targetReads += 1; + cancellation.cancel(); + return "Main"; + }, + workspacePath: "unused", + }; + const text = await invokePromptly(tool, { + get input() { + inputReads += 1; + return input; + }, + toolInvocationToken: undefined, + }); + assert.strictEqual(text, "\u2717 Operation cancelled by user"); + assert.ok(inputReads > 0); + assert.ok(targetReads > 0); + assert.ok(launchTelemetry.length > 0); + assert.strictEqual(errorTelemetry.length, 0); + assert.strictEqual(sideEffects, 0); + } + setup(() => { registeredTool = undefined; registeredName = undefined; + lmRegistration = undefined; + registrations = []; cleanups = []; + cancellation = new vscode.CancellationTokenSource(); + activation = deferred(); + serverReady = deferred(); + javaApi = { + serverMode: utility.ServerMode.STANDARD, + status: "Started", + serverReady() { + serverReadyCalls += 1; + return serverReady.promise; + }, + }; + apiCalls = 0; + serverReadyCalls = 0; + observerStarts = 0; + observerDisposals = 0; + lmDisposals = 0; + noConfigStateReads = 0; + javaStateReads = 0; + inputReads = 0; + targetReads = 0; + javaVersionProbes = 0; + launchTelemetry = []; + errorTelemetry = []; sideEffects = 0; const registerTool: typeof vscode.lm.registerTool = (name, tool) => { registeredName = name; registeredTool = tool; - return new vscode.Disposable(() => { }); + lmRegistration = new vscode.Disposable(() => { lmDisposals += 1; }); + return lmRegistration; }; overrideProperty(vscode.lm, "registerTool", { value: registerTool }); - overrideProperty(telemetry, "sendInfo", { value: () => { } }); + overrideProperty(utility, "getJavaExtensionAPI", { + value: () => { + apiCalls += 1; + return activation.promise; + }, + }); + const observe = javaServerReadiness.observeJavaServerReadiness; + overrideProperty(javaServerReadiness, "observeJavaServerReadiness", { + value: (): javaServerReadiness.JavaServerReadiness => { + observerStarts += 1; + assert.ok(registeredTool, "The LM implementation must be registered before Java initialization starts"); + const observer = observe(); + return { + getState() { + javaStateReads += 1; + return observer.getState(); + }, + dispose() { + observerDisposals += 1; + observer.dispose(); + }, + }; + }, + }); + overrideProperty(telemetry, "sendInfo", { + value: (...args: Parameters) => { launchTelemetry.push(args); }, + }); + overrideProperty(telemetry, "sendError", { + value: (...args: Parameters) => { errorTelemetry.push(args); }, + }); + overrideProperty(vscode.extensions, "getExtension", { + value: () => { + javaVersionProbes += 1; + return undefined; + }, + }); const unexpectedSideEffect = (): never => { sideEffects += 1; - throw new Error("The AI launch tool must not touch sessions, terminals, or builds when disabled"); + throw new Error("The AI launch tool must not touch sessions, terminals, or builds before readiness or after cancellation"); }; overrideProperty(vscode.debug, "activeDebugSession", { get: unexpectedSideEffect }); + overrideProperty(vscode.debug, "startDebugging", { value: 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 }); + overrideProperty(vscode.tasks, "executeTask", { value: unexpectedSideEffect }); }); - teardown(() => { + teardown(async () => { + for (const registration of registrations.reverse()) { + registration.dispose(); + } + activation.resolve(undefined); + serverReady.resolve(true); + await nextTurn(); + cancellation.dispose(); 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"); - }, + for (const { state, message } of terminalStates) { + test(`preserves the ${state.status} snapshot without live settings or Java initialization`, async () => { + overrideProperty(vscode.workspace, "getConfiguration", { + value: () => { + throw new Error("The launch tool must use the activation snapshot instead of reading live settings"); + }, + }); + const { tool } = registerReadiness(state); + + assert.strictEqual(await invokeBlockedTool(tool), message + noChanges); + assert.strictEqual(observerStarts, 0); + assert.strictEqual(apiCalls, 0); + assert.strictEqual(serverReadyCalls, 0); + assert.strictEqual(javaStateReads, 0); }); - 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(`prefers the current No-Config ${state.status} state over a Java initialization failure`, async () => { + const { tool } = registerReadiness({ status: "initializing" }); + activation.reject(new Error("Private activation failure at C:\\private\\workspace")); + await nextTurn(); + assert.strictEqual(errorTelemetry.length, 1); + noConfigState = state; + + assert.strictEqual(await invokeBlockedTool(tool), message + noChanges); + assert.strictEqual(observerStarts, 1); + assert.strictEqual(apiCalls, 1); + }); + } + + test("refuses pending Java activation promptly without restarting observation", async () => { + const { tool } = registerReadiness({ status: "ready" }); + + const results = await Promise.all([invokeBlockedTool(tool), invokeBlockedTool(tool)]); + assert.deepStrictEqual(results, [javaNotReady + noChanges, javaNotReady + noChanges]); + assert.strictEqual(observerStarts, 1); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, 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(); + test("refuses pending serverReady despite Standard mode and Started status, without queueing a launch", async () => { + const { tool } = registerReadiness({ status: "ready" }); + activation.resolve(javaApi); + await nextTurn(); + + assert.strictEqual(await invokeBlockedTool(tool), javaNotReady + noChanges); + assert.strictEqual(serverReadyCalls, 1); + serverReady.resolve(true); + await nextTurn(); + assertNoLaunchWork(); + assert.strictEqual(observerStarts, 1); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, 1); + }); + + test("reports Java initialization failure instead of waiting for No-Config preparation", async () => { + const { tool } = registerReadiness({ status: "initializing" }); + activation.reject(new Error("Private activation failure at C:\\private\\workspace")); + await nextTurn(); + assert.strictEqual(errorTelemetry.length, 1); + + assert.strictEqual(await invokeBlockedTool(tool), javaInitializationFailed + noChanges); + assert.strictEqual(serverReadyCalls, 0); + }); + + test("returns update guidance when the Java readiness API is missing", async () => { + const { tool } = registerReadiness({ status: "ready" }); + activation.resolve({ serverMode: utility.ServerMode.STANDARD, status: "Started" }); + await nextTurn(); + assert.strictEqual(errorTelemetry.length, 1); + + assert.strictEqual(await invokeBlockedTool(tool), + "JAVA_INIT_FAILED: Java language server readiness API is unavailable. " + + "Update Language Support for Java by Red Hat and reload VS Code before retrying." + noChanges); + assert.strictEqual(serverReadyCalls, 0); + }); + + test("checks current Java Error status even after serverReady succeeded", async () => { + const { tool } = registerReadiness({ status: "ready" }); + activation.resolve(javaApi); + serverReady.resolve(true); + await nextTurn(); + javaApi.status = "Error"; + + assert.strictEqual(await invokeBlockedTool(tool), javaInitializationFailed + noChanges); + }); + + for (const ready of [false, true]) { + test(`pre-cancelled invocation bypasses inputs and readiness queries when Java is ${ready ? "ready" : "pending"}`, async () => { + const { tool } = registerReadiness({ status: "ready" }); + if (ready) { + activation.resolve(javaApi); + serverReady.resolve(true); + await nextTurn(); + } + cancellation.cancel(); + const readsBefore = [noConfigStateReads, javaStateReads]; + + assert.strictEqual(await invokeBlockedTool(tool), "CANCELLED: Operation cancelled by user." + noChanges); + assert.deepStrictEqual([noConfigStateReads, javaStateReads], readsBefore); + }); + } + + test("checks cancellation before registration disposal or readiness", async () => { + const { tool, disposable } = registerReadiness({ status: "ready" }); + disposable.dispose(); cancellation.cancel(); - cleanups.push(() => cancellation.dispose()); + const readsBefore = [noConfigStateReads, javaStateReads]; - 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); + assert.strictEqual(await invokeBlockedTool(tool), "CANCELLED: Operation cancelled by user." + noChanges); + assert.deepStrictEqual([noConfigStateReads, javaStateReads], readsBefore); + }); + + for (const phase of ["activation", "serverReady"]) { + for (const outcome of ["success", "rejection"]) { + test(`registration owns its observer and ignores late ${phase} ${outcome} after disposal`, async () => { + const { tool, disposable } = registerReadiness({ status: "ready" }); + if (phase === "serverReady") { + activation.resolve(javaApi); + await nextTurn(); + assert.strictEqual(serverReadyCalls, 1); + } + disposable.dispose(); + disposable.dispose(); + assert.strictEqual(observerDisposals, 1); + assert.strictEqual(lmDisposals, 1); + const readsBefore = [noConfigStateReads, javaStateReads]; + + if (outcome === "rejection") { + const pending = phase === "activation" ? activation : serverReady; + pending.reject(new Error("Late failure at C:\\private\\workspace")); + } else if (phase === "activation") { + activation.resolve(javaApi); + } else { + serverReady.resolve(true); + } + await nextTurn(); + + assert.strictEqual(await invokeBlockedTool(tool), toolDisposed + noChanges); + assert.deepStrictEqual([noConfigStateReads, javaStateReads], readsBefore); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, phase === "activation" ? 0 : 1); + assert.strictEqual(errorTelemetry.length, 0); + }); + } + } + + test("does not auto-launch a refused invocation when Java becomes ready, but permits a new invocation", async () => { + const { tool } = registerReadiness({ status: "ready" }); + assert.strictEqual(await invokeBlockedTool(tool), javaNotReady + noChanges); + + activation.resolve(javaApi); + await nextTurn(); + assertNoLaunchWork(); + serverReady.resolve(true); + await nextTurn(); + assertNoLaunchWork(); + + await invokeAndCancelAfterReadiness(tool); + assert.strictEqual(apiCalls, 1); + assert.strictEqual(serverReadyCalls, 1); + }); + + test("refuses unfinished No-Config preparation promptly and requires a new invocation after it finishes", async () => { + const { tool } = registerReadiness({ status: "initializing" }); + activation.resolve(javaApi); + serverReady.resolve(true); + await nextTurn(); + + assert.strictEqual(await invokeBlockedTool(tool), + "NO_CONFIG_NOT_READY: Java No-Config Debug is still preparing its terminal environment. " + + "Retry after preparation completes; do not retry in a loop or change project code to resolve this readiness condition." + noChanges); + + noConfigState = { status: "ready" }; + await nextTurn(); + assertNoLaunchWork(); + await invokeAndCancelAfterReadiness(tool); }); }); diff --git a/test/noConfigDebugStorage.test.ts b/test/noConfigDebugStorage.test.ts index ae335d41..05e9eac5 100644 --- a/test/noConfigDebugStorage.test.ts +++ b/test/noConfigDebugStorage.test.ts @@ -12,6 +12,7 @@ import { registerNoConfigDebug } from "../src/noConfigDebugInit"; import { buildNoConfigPathAppendValue } from "../src/pathUtil"; import * as utility from "../src/utility"; import { createFakeCollection, FakeCollection } from "./helpers/environmentVariableCollection"; +import { deferred } from "./helpers/deferred"; suite("No-Config Debug workspace storage", () => { let tempDir: string; @@ -33,12 +34,15 @@ suite("No-Config Debug workspace storage", () => { cleanups.push(() => Object.defineProperty(target, key, descriptor)); } + function startRegistration(storage: vscode.Uri | undefined = storageUri, enabled: boolean = true) { + const registration = registerNoConfigDebug(collection, extPath, storage, enabled); + cleanups.push(() => registration.dispose()); + return registration; + } + 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; + const registration = startRegistration(storage, enabled); + return (await registration.ready).status === "ready" ? registration : undefined; } function endpointPath(): string { @@ -147,7 +151,10 @@ suite("No-Config Debug workspace storage", () => { }); test("does not report a missing workspace when explicitly disabled", async () => { - assert.strictEqual(await registerNoConfigDebug(collection, extPath, undefined, false), undefined); + const registration = registerNoConfigDebug(collection, extPath, undefined, false); + cleanups.push(() => registration.dispose()); + assert.deepStrictEqual(registration.getState(), { status: "disabled" }); + assert.deepStrictEqual(await registration.ready, { status: "disabled" }); assert.strictEqual(errors.length, 0); assert.strictEqual(warnings.length, 0); assert.strictEqual(patterns.length, 0); @@ -299,8 +306,11 @@ suite("No-Config Debug workspace storage", () => { 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); + const registration = registerNoConfigDebug(collection, extPath, undefined); + cleanups.push(() => registration.dispose()); + assert.deepStrictEqual(await registration.ready, { + status: "failed", message: "No workspace folder found for Java No-Config Debug.", + }); assert.strictEqual(collection.get("VSCODE_JDWP_ADAPTER_ENDPOINTS"), undefined); assert.strictEqual(collection.get("VSCODE_JAVA_EXEC"), undefined); assert.strictEqual(collection.get("PATH"), undefined); @@ -311,6 +321,173 @@ suite("No-Config Debug workspace storage", () => { assert.strictEqual(warnings.length, 0); }); + test("exposes synchronous snapshots of one background initialization", async () => { + const javaHome = deferred(); + const requested = deferred(); + let javaRequests = 0; + replaceProperty(utility, "getJavaHome", () => { + javaRequests += 1; + requested.resolve(); + return javaHome.promise; + }); + const registration = startRegistration(); + assert.deepStrictEqual(registration.getState(), { status: "initializing" }); + try { + await requested.promise; + assert.deepStrictEqual(registration.getState(), { status: "initializing" }); + assert.deepStrictEqual(registration.getState(), { status: "initializing" }); + assert.strictEqual(javaRequests, 1); + assert.strictEqual(watcherDisposed, false); + assert.strictEqual(collection.get("PATH"), undefined); + + javaHome.resolve(path.join(tempDir, "jdk")); + assert.deepStrictEqual(await registration.ready, { status: "ready" }); + assert.deepStrictEqual(registration.getState(), { status: "ready" }); + assert.ok(collection.get("PATH")); + assert.strictEqual(patterns.length, 1); + assert.strictEqual(javaRequests, 1); + } finally { + javaHome.resolve(""); + await registration.ready; + } + }); + + test("reports disposal immediately while directory setup remains pending", async () => { + const directory = deferred(); + replaceProperty(fs.promises, "mkdir", () => directory.promise); + const registration = startRegistration(); + assert.deepStrictEqual(registration.getState(), { status: "initializing" }); + registration.dispose(); + assert.deepStrictEqual(registration.getState(), { status: "disposed" }); + directory.resolve(undefined); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepStrictEqual(registration.getState(), { status: "disposed" }); + assert.strictEqual(patterns.length, 0); + }); + + for (const stage of ["mkdir", "unlink"] as const) { + test(`does not continue setup when disposed during ${stage}`, async () => { + const pending = deferred(); + const requested = deferred(); + replaceProperty(fs.promises, stage, () => { + requested.resolve(); + return pending.promise; + }); + const registration = startRegistration(); + await requested.promise; + registration.dispose(); + assert.deepStrictEqual(await registration.ready, { status: "disposed" }); + assert.deepStrictEqual(registration.getState(), { status: "disposed" }); + pending.resolve(undefined); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(patterns.length, 0); + assert.strictEqual(collection.__calls.replace, 0); + assert.strictEqual(collection.__calls.append, 0); + assert.strictEqual(errors.length, 0); + }); + } + + for (const rejectJavaHome of [false, true]) { + test(`disposes partial listeners and ignores late Java ${rejectJavaHome ? "failure" : "resolution"}`, async () => { + const javaHome = deferred(); + const requested = deferred(); + let sessionListenerDisposed = false; + replaceProperty(utility, "getJavaHome", () => { + requested.resolve(); + return javaHome.promise; + }); + replaceProperty(vscode.debug, "onDidTerminateDebugSession", + () => new vscode.Disposable(() => { sessionListenerDisposed = true; })); + const registration = startRegistration(); + await requested.promise; + registration.dispose(); + assert.strictEqual(watcherDisposed, true); + assert.strictEqual(sessionListenerDisposed, true); + assert.deepStrictEqual(registration.getState(), { status: "disposed" }); + assert.deepStrictEqual(await registration.ready, { status: "disposed" }); + const calls = { ...collection.__calls }; + if (rejectJavaHome) { + javaHome.reject(new Error("Java became unavailable")); + } else { + javaHome.resolve(path.join(tempDir, "jdk")); + } + await new Promise((resolve) => setImmediate(resolve)); + assert.deepStrictEqual(collection.__calls, calls); + assert.strictEqual(collection.get("PATH"), undefined); + assert.strictEqual(errors.length, 0); + assert.strictEqual(warnings.length, 0); + }); + } + + test("disposes partial resources and reports unexpected initialization failures without rejecting readiness", async () => { + seedCachedEnvironment(); + replaceProperty(vscode.debug, "onDidTerminateDebugSession", () => { + throw Object.assign(new Error(`Cannot subscribe in ${tempDir}`), { code: "EACCES" }); + }); + const registration = startRegistration(); + const result = await registration.ready; + assert.strictEqual(result.status, "failed"); + assert.ok(result.status === "failed"); + assert.ok(result.message.includes("EACCES")); + assert.strictEqual(result.message.includes(tempDir), false); + assert.deepStrictEqual(registration.getState(), result); + assertUnavailable(undefined, "EACCES"); + assert.strictEqual(watcherDisposed, true); + }); + + test("ignores an in-flight directory failure after disposal", async () => { + const directory = deferred(); + replaceProperty(fs.promises, "mkdir", () => directory.promise); + const registration = startRegistration(); + registration.dispose(); + directory.reject(Object.assign(new Error("Storage is no longer available"), { code: "EACCES" })); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepStrictEqual(await registration.ready, { status: "disposed" }); + assert.strictEqual(errors.length, 0); + assert.strictEqual(warnings.length, 0); + assert.strictEqual(patterns.length, 0); + }); + + test("does not clean up endpoint data after an in-flight attach completes following disposal", async () => { + const registration = startRegistration(); + await registration.ready; + const endpoint = endpointPath(); + const attached = deferred(); + const requested = deferred(); + replaceProperty(vscode.debug, "startDebugging", () => { + requested.resolve(); + return attached.promise; + }); + await fs.promises.writeFile(endpoint, JSON.stringify({ client: { port: 54321 } })); + created.fire(vscode.Uri.file(endpoint)); + await requested.promise; + registration.dispose(); + attached.resolve(true); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(fs.existsSync(endpoint), true); + assert.strictEqual(errors.length, 0); + }); + + test("does not attach an endpoint event queued before disposal", async () => { + const registration = startRegistration(); + await registration.ready; + const endpoint = endpointPath(); + let attachCalls = 0; + replaceProperty(vscode.debug, "startDebugging", async () => { + attachCalls += 1; + return true; + }); + await fs.promises.writeFile(endpoint, JSON.stringify({ client: { port: 54321 } })); + created.fire(vscode.Uri.file(endpoint)); + registration.dispose(); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.strictEqual(attachCalls, 0); + assert.strictEqual(fs.existsSync(endpoint), true); + assert.strictEqual(errors.length, 0); + assert.deepStrictEqual(registration.getState(), { status: "disposed" }); + assert.deepStrictEqual(await registration.ready, { status: "ready" }); + }); + for (const eventType of ["create", "change"]) { test(`handles endpoint ${eventType} events while Java-home resolution is pending`, async function() { this.timeout(5000);