From 55862237b4f06e3dd5292391afd24ba65e1ce915 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Tue, 8 Sep 2026 17:11:11 +0800 Subject: [PATCH 1/2] fix: decouple no-config debug readiness from core activation --- README.md | 2 + bundled/agents/README.md | 2 + bundled/scripts/noConfigScripts/README.md | 14 +- src/extension.ts | 23 +-- src/languageModelTool.ts | 31 ++- src/noConfigDebugInit.ts | 187 ++++++++++++++--- test/helpers/deferred.ts | 12 ++ test/noConfigDebugActivation.test.ts | 240 ++++++++++++++++++++++ test/noConfigDebugInit.test.ts | 26 +++ test/noConfigDebugSettings.test.ts | 186 +++++++++++++---- test/noConfigDebugStorage.test.ts | 216 ++++++++++++++++++- 11 files changed, 841 insertions(+), 98 deletions(-) create mode 100644 test/helpers/deferred.ts create mode 100644 test/noConfigDebugActivation.test.ts diff --git a/README.md b/README.md index 56fb3dce..41f86454 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 waits for it to be ready (up to 60 seconds, cancellable). 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..8e6f95c8 100644 --- a/bundled/agents/README.md +++ b/bundled/agents/README.md @@ -167,6 +167,8 @@ 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 also waits for No-Config Debug initialization to finish before building, creating a terminal, or stopping an existing session. This wait is cancellable and limited to 60 seconds. A timeout does not stop background initialization; you can retry later. Initialization failures are reported without attempting to launch. + Ensure: - Your project compiles successfully - No other debug session is running diff --git a/bundled/scripts/noConfigScripts/README.md b/bundled/scripts/noConfigScripts/README.md index caa82480..ec40176a 100644 --- a/bundled/scripts/noConfigScripts/README.md +++ b/bundled/scripts/noConfigScripts/README.md @@ -4,13 +4,23 @@ 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` tool waits for the shared initialization task before inspecting the launch input, building, creating a terminal, or stopping an existing debug session. Each wait is cancellable and limited to 60 seconds. Cancelling or timing out one invocation does not cancel initialization or another invocation's wait; a later invocation can retry. Initialization failure or extension disposal returns an explanatory result rather than attempting a launch. + +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 +114,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/src/extension.ts b/src/extension.ts index 7696331c..6d28a91f 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 waits + * for No-Config Debug readiness at invocation, not during core activation. */ -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/languageModelTool.ts b/src/languageModelTool.ts index cdfa6dfb..55b95c65 100644 --- a/src/languageModelTool.ts +++ b/src/languageModelTool.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { ENABLE_NO_CONFIG_DEBUG } from "./constants"; +import { NoConfigDebugRegistration } from "./noConfigDebugInit"; import { beginDebugSessionInvocation, classifyBreakpoint, @@ -115,7 +116,7 @@ interface LanguageModelTool { */ 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; @@ -126,12 +127,32 @@ export function registerLanguageModelTool( const tool: LanguageModelTool = { async invoke(options: { input: DebugJavaApplicationInput }, token: vscode.CancellationToken): Promise { - if (!noConfigDebugEnabled) { + const readiness = await noConfigDebug.waitUntilReady(token); + if (readiness.status !== "ready") { + let message: string; + switch (readiness.status) { + case "disabled": + message = `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."; + break; + case "failed": + message = `${readiness.message} This tool cannot launch until initialization succeeds. ` + + "Resolve the initialization problem and reload VS Code before retrying."; + break; + case "cancelled": + message = "Operation cancelled by user while waiting for Java No-Config Debug initialization."; + break; + case "timeout": + message = "Timed out waiting for Java No-Config Debug initialization. " + + "Initialization is still running; you can retry this tool later."; + break; + case "disposed": + message = "Java No-Config Debug has been disposed. Reload VS Code before retrying this tool."; + break; + } 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.", + `${message} Standard Java launch/attach debugging remains available.`, ), ]); } diff --git a/src/noConfigDebugInit.ts b/src/noConfigDebugInit.ts index f51a2c44..b91d1fe6 100644 --- a/src/noConfigDebugInit.ts +++ b/src/noConfigDebugInit.ts @@ -12,6 +12,22 @@ 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 NoConfigDebugWaitResult = NoConfigDebugResult | { status: "cancelled" | "timeout" }; + +export interface NoConfigDebugRegistration extends vscode.Disposable { + readonly ready: Promise; + waitUntilReady(token: vscode.CancellationToken, timeoutMs?: number): Promise; +} + +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 +49,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 +77,110 @@ 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 complete!: (result: NoConfigDebugResult) => void; + const ready = new Promise((resolve) => { complete = resolve; }); + 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, + async waitUntilReady(token, timeoutMs = 60000): Promise { + if (token.isCancellationRequested) { + return { status: "cancelled" }; + } + let listener: vscode.Disposable | undefined; + let timeout: NodeJS.Timeout | undefined; + try { + const result = await Promise.race([ + ready, + new Promise((resolve) => { + listener = token.onCancellationRequested(() => resolve({ status: "cancelled" })); + timeout = setTimeout(() => resolve({ status: "timeout" }), timeoutMs); + }), + ]); + if (token.isCancellationRequested) { + return { status: "cancelled" }; + } + return lifetime.token.isCancellationRequested ? { status: "disposed" } : result; + } finally { + listener?.dispose(); + if (timeout) { + clearTimeout(timeout); + } + } + }, + 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 +190,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 +201,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 +236,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 +305,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 +336,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 +360,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 +394,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 +406,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/test/helpers/deferred.ts b/test/helpers/deferred.ts new file mode 100644 index 00000000..43c2fe9f --- /dev/null +++ b/test/helpers/deferred.ts @@ -0,0 +1,12 @@ +// 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 }; +} diff --git a/test/noConfigDebugActivation.test.ts b/test/noConfigDebugActivation.test.ts new file mode 100644 index 00000000..b1a89215 --- /dev/null +++ b/test/noConfigDebugActivation.test.ts @@ -0,0 +1,240 @@ +// 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 } 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; + }), + async waitUntilReady() { + assert.fail("Activation must not wait for No-Config Debug readiness"); + }, + 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(); + } + } + }); + } +}); + +async function withinDeadline(promise: Promise, message: string): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(message)), 500); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +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..c697ea15 100644 --- a/test/noConfigDebugSettings.test.ts +++ b/test/noConfigDebugSettings.test.ts @@ -9,6 +9,8 @@ import * as telemetry from "vscode-extension-telemetry-wrapper"; import { ENABLE_NO_CONFIG_DEBUG } from "../src/constants"; import { registerLanguageModelTool } from "../src/languageModelTool"; +import { NoConfigDebugRegistration, NoConfigDebugWaitResult } from "../src/noConfigDebugInit"; +import { deferred } from "./helpers/deferred"; suite("No-Config Debug setting", () => { test("is a default-enabled window-scoped setting with localized descriptions", async () => { @@ -30,10 +32,14 @@ suite("No-Config Debug setting", () => { }); }); -suite("No-Config Debug AI opt-out", () => { +suite("No-Config Debug AI startup readiness", () => { let registeredTool: vscode.LanguageModelTool | undefined; let registeredName: string | undefined; let cleanups: (() => void)[]; + let cancellation: vscode.CancellationTokenSource; + let waitedTokens: vscode.CancellationToken[]; + let inputReads: number; + let telemetryCalls: number; let sideEffects: number; function overrideProperty(target: object, key: string, descriptor: PropertyDescriptor): void { @@ -43,10 +49,67 @@ suite("No-Config Debug AI opt-out", () => { cleanups.push(() => Object.defineProperty(target, key, original)); } + function registerReadiness(result: NoConfigDebugWaitResult | Promise): vscode.LanguageModelTool { + const readiness: Pick = { + async waitUntilReady(token) { + waitedTokens.push(token); + return result; + }, + }; + const context: Pick = { subscriptions: [] }; + const disposable = registerLanguageModelTool(context, readiness); + assert.ok(disposable); + cleanups.push(() => disposable.dispose()); + assert.strictEqual(context.subscriptions[0], disposable); + assert.strictEqual(registeredName, "debug_java_application"); + assert.ok(registeredTool); + return registeredTool; + } + + 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 assertWaitedWithInvocationToken(): void { + assert.strictEqual(waitedTokens.length, 1); + assert.strictEqual(waitedTokens[0], cancellation.token); + } + + function assertNoLaunchWork(): void { + assert.strictEqual(inputReads, 0); + assert.strictEqual(telemetryCalls, 0); + assert.strictEqual(sideEffects, 0); + } + + async function invokeBlockedTool(readiness: NoConfigDebugWaitResult): Promise { + const tool = registerReadiness(readiness); + const result = await tool.invoke({ + get input(): never { + inputReads += 1; + throw new Error("The launch tool must not inspect inputs before initialization is ready"); + }, + toolInvocationToken: undefined, + }, cancellation.token); + assertWaitedWithInvocationToken(); + assertNoLaunchWork(); + const text = resultText(result); + assert.ok(text.includes("Standard Java launch/attach debugging remains available")); + return text; + } + setup(() => { registeredTool = undefined; registeredName = undefined; cleanups = []; + cancellation = new vscode.CancellationTokenSource(); + cleanups.push(() => cancellation.dispose()); + waitedTokens = []; + inputReads = 0; + telemetryCalls = 0; sideEffects = 0; const registerTool: typeof vscode.lm.registerTool = (name, tool) => { registeredName = name; @@ -54,12 +117,15 @@ suite("No-Config Debug AI opt-out", () => { return new vscode.Disposable(() => { }); }; overrideProperty(vscode.lm, "registerTool", { value: registerTool }); - overrideProperty(telemetry, "sendInfo", { value: () => { } }); + const recordTelemetry = () => { telemetryCalls += 1; }; + overrideProperty(telemetry, "sendInfo", { value: recordTelemetry }); + overrideProperty(telemetry, "sendError", { value: recordTelemetry }); 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 }); @@ -72,60 +138,102 @@ suite("No-Config Debug AI opt-out", () => { } }); - test("returns opt-in guidance before inspecting inputs or changing sessions and terminals", async () => { + test("returns disabled snapshot guidance without rereading settings, inspecting inputs, or doing launch work", async () => { overrideProperty(vscode.workspace, "getConfiguration", { value: () => { throw new Error("The launch tool must use the activation snapshot instead of reading live settings"); }, }); - const context: Pick = { subscriptions: [] }; - const disposable = registerLanguageModelTool(context, false); - assert.ok(disposable); - cleanups.push(() => disposable.dispose()); - assert.strictEqual(context.subscriptions[0], disposable); - assert.strictEqual(registeredName, "debug_java_application"); - assert.ok(registeredTool); + const text = await invokeBlockedTool({ status: "disabled" }); + assert.ok(text.includes(ENABLE_NO_CONFIG_DEBUG)); + assert.ok(text.includes("enable that setting")); + assert.ok(text.includes("reload VS Code")); + assert.ok(text.includes("recreate existing terminals")); + }); + + test("waits for pending readiness before inspecting inputs, emitting telemetry, or doing launch work", async () => { + const readiness = deferred(); + cleanups.push(() => readiness.resolve({ status: "disposed" })); + const tool = registerReadiness(readiness.promise); + let targetReads = 0; 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"); + targetReads += 1; + return "Main"; }, + workspacePath: "unused", }; - 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")); + const invocation = Promise.resolve(tool.invoke({ + get input() { + inputReads += 1; + return input; + }, + toolInvocationToken: undefined, + }, cancellation.token)); + let settled = false; + void invocation.then(() => { settled = true; }, () => { settled = true; }); + await Promise.resolve(); + + assertWaitedWithInvocationToken(); + assert.strictEqual(settled, false); + assert.strictEqual(targetReads, 0); + assertNoLaunchWork(); + + cancellation.cancel(); + readiness.resolve({ status: "ready" }); + const text = resultText(await invocation); + assert.ok(text.includes("Operation cancelled by user")); + assert.ok(inputReads > 0); + assert.ok(targetReads > 0); + assert.ok(telemetryCalls > 0); assert.strictEqual(sideEffects, 0); }); - test("keeps the existing launch flow enabled by default", async () => { - const context: Pick = { subscriptions: [] }; - const disposable = registerLanguageModelTool(context); - assert.ok(disposable); - cleanups.push(() => disposable.dispose()); - assert.ok(registeredTool); - const cancellation = new vscode.CancellationTokenSource(); + test("returns initialization error and recovery guidance without doing launch work", async () => { + const message = "Java No-Config Debug initialization failed (EACCES)."; + const text = await invokeBlockedTool({ status: "failed", message }); + assert.ok(text.includes(message)); + assert.ok(text.includes("cannot launch until initialization succeeds")); + assert.ok(text.includes("Resolve the initialization problem and reload VS Code")); + assert.strictEqual(text.includes("enable that setting"), false); + }); + + test("returns cancellation while waiting without inspecting inputs or doing launch work", async () => { cancellation.cancel(); - cleanups.push(() => cancellation.dispose()); + const text = await invokeBlockedTool({ status: "cancelled" }); + assert.ok(text.includes("Operation cancelled by user while waiting")); + assert.strictEqual(text.includes("enable that setting"), false); + }); + + test("returns retry guidance on readiness timeout without doing launch work", async () => { + const text = await invokeBlockedTool({ status: "timeout" }); + assert.ok(text.includes("Timed out waiting for Java No-Config Debug initialization")); + assert.ok(text.includes("Initialization is still running")); + assert.ok(text.includes("retry this tool later")); + assert.strictEqual(text.includes("enable that setting"), false); + }); - const result = await registeredTool.invoke({ + test("returns reload guidance when initialization is disposed without doing launch work", async () => { + const text = await invokeBlockedTool({ status: "disposed" }); + assert.ok(text.includes("has been disposed")); + assert.ok(text.includes("Reload VS Code before retrying this tool")); + assert.strictEqual(text.includes("enable that setting"), false); + }); + + test("continues the existing launch flow when readiness succeeds", async () => { + const tool = registerReadiness({ status: "ready" }); + cancellation.cancel(); + + const result = await tool.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); + assertWaitedWithInvocationToken(); + const text = resultText(result); + assert.ok(text.includes("Operation cancelled by user")); + assert.strictEqual(text.includes(ENABLE_NO_CONFIG_DEBUG), false); + assert.ok(telemetryCalls > 0); assert.strictEqual(sideEffects, 0); }); }); diff --git a/test/noConfigDebugStorage.test.ts b/test/noConfigDebugStorage.test.ts index ae335d41..971917a2 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,9 @@ 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(await registration.ready, { status: "disabled" }); assert.strictEqual(errors.length, 0); assert.strictEqual(warnings.length, 0); assert.strictEqual(patterns.length, 0); @@ -299,8 +305,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 +320,197 @@ suite("No-Config Debug workspace storage", () => { assert.strictEqual(warnings.length, 0); }); + test("shares readiness and lets one caller cancel without cancelling initialization", async () => { + const javaHome = deferred(); + const requested = deferred(); + replaceProperty(utility, "getJavaHome", () => { + requested.resolve(); + return javaHome.promise; + }); + const registration = startRegistration(); + const first = new vscode.CancellationTokenSource(); + const second = new vscode.CancellationTokenSource(); + cleanups.push(() => first.dispose(), () => second.dispose()); + try { + await requested.promise; + let ready = false; + const pending = registration.waitUntilReady(second.token).then((result) => { + ready = true; + return result; + }); + const cancelled = registration.waitUntilReady(first.token); + first.cancel(); + assert.deepStrictEqual(await cancelled, { status: "cancelled" }); + assert.strictEqual(ready, false); + assert.strictEqual(watcherDisposed, false); + assert.strictEqual(collection.get("PATH"), undefined); + + javaHome.resolve(path.join(tempDir, "jdk")); + assert.deepStrictEqual(await pending, { status: "ready" }); + assert.deepStrictEqual(await registration.ready, { status: "ready" }); + assert.ok(collection.get("PATH")); + assert.strictEqual(patterns.length, 1); + } finally { + javaHome.resolve(""); + await registration.ready; + } + }); + + test("bounds each wait and allows retrying the same initialization after timeout", async () => { + const javaHome = deferred(); + replaceProperty(utility, "getJavaHome", () => javaHome.promise); + const registration = startRegistration(); + const caller = new vscode.CancellationTokenSource(); + cleanups.push(() => caller.dispose()); + try { + assert.deepStrictEqual(await registration.waitUntilReady(caller.token, 10), { status: "timeout" }); + javaHome.resolve(path.join(tempDir, "jdk")); + assert.deepStrictEqual(await registration.waitUntilReady(caller.token), { status: "ready" }); + assert.strictEqual(patterns.length, 1); + } finally { + javaHome.resolve(""); + await registration.ready; + } + }); + + test("returns immediately for an already cancelled caller", async () => { + const directory = deferred(); + replaceProperty(fs.promises, "mkdir", () => directory.promise); + const registration = startRegistration(); + const caller = new vscode.CancellationTokenSource(); + cleanups.push(() => caller.dispose()); + caller.cancel(); + assert.deepStrictEqual(await registration.waitUntilReady(caller.token), { status: "cancelled" }); + registration.dispose(); + directory.resolve(undefined); + await new Promise((resolve) => setImmediate(resolve)); + 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" }); + 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(); + const caller = new vscode.CancellationTokenSource(); + cleanups.push(() => caller.dispose()); + const waiting = registration.waitUntilReady(caller.token); + await requested.promise; + registration.dispose(); + assert.strictEqual(watcherDisposed, true); + assert.strictEqual(sessionListenerDisposed, true); + assert.deepStrictEqual(await waiting, { 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); + 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); + const caller = new vscode.CancellationTokenSource(); + cleanups.push(() => caller.dispose()); + assert.deepStrictEqual(await registration.waitUntilReady(caller.token), { status: "disposed" }); + }); + for (const eventType of ["create", "change"]) { test(`handles endpoint ${eventType} events while Java-home resolution is pending`, async function() { this.timeout(5000); From a1fd391fe4e7a911743963f4f87d4d89bce4621f Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 14 Sep 2026 16:22:00 +0800 Subject: [PATCH 2/2] fix: fail fast when Java debug prerequisites are not ready --- README.md | 2 +- bundled/agents/README.md | 4 +- bundled/agents/debug.agent.md | 8 +- bundled/scripts/noConfigScripts/README.md | 6 +- package.json | 2 +- .../javaDebugContext.instructions.md | 4 +- .../java-launch-troubleshooting/SKILL.md | 7 +- src/extension.ts | 4 +- src/javaServerReadiness.ts | 78 +++ src/languageModelTool.ts | 87 +++- src/noConfigDebugInit.ts | 40 +- src/utility.ts | 22 +- test/helpers/deferred.ts | 16 + test/javaExtensionAPI.test.ts | 140 ++++++ test/javaServerReadiness.test.ts | 254 ++++++++++ test/noConfigDebugActivation.test.ts | 22 +- test/noConfigDebugSettings.test.ts | 466 +++++++++++++----- test/noConfigDebugStorage.test.ts | 63 +-- 18 files changed, 979 insertions(+), 246 deletions(-) create mode 100644 src/javaServerReadiness.ts create mode 100644 test/javaExtensionAPI.test.ts create mode 100644 test/javaServerReadiness.test.ts diff --git a/README.md b/README.md index 41f86454..389d577b 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ 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 waits for it to be ready (up to 60 seconds, cancellable). A terminal opened before preparation finishes may need to be recreated to receive the environment contributions. +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 diff --git a/bundled/agents/README.md b/bundled/agents/README.md index 8e6f95c8..ca8abeaf 100644 --- a/bundled/agents/README.md +++ b/bundled/agents/README.md @@ -167,7 +167,9 @@ 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 also waits for No-Config Debug initialization to finish before building, creating a terminal, or stopping an existing session. This wait is cancellable and limited to 60 seconds. A timeout does not stop background initialization; you can retry later. Initialization failures are reported without attempting to launch. +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 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 ec40176a..34d984c7 100644 --- a/bundled/scripts/noConfigScripts/README.md +++ b/bundled/scripts/noConfigScripts/README.md @@ -15,7 +15,11 @@ Note: `JAVA_TOOL_OPTIONS` is NOT set globally to avoid affecting other Java tool 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` tool waits for the shared initialization task before inspecting the launch input, building, creating a terminal, or stopping an existing debug session. Each wait is cancellable and limited to 60 seconds. Cancelling or timing out one invocation does not cancel initialization or another invocation's wait; a later invocation can retry. Initialization failure or extension disposal returns an explanatory result rather than attempting a launch. +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. 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 6d28a91f..f65e6054 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -111,8 +111,8 @@ export async function deactivate() { const delay = promisify(setTimeout); /** - * Register tools when the Java extension is installed. The launch tool waits - * for No-Config Debug readiness at invocation, not during core activation. + * Register tools when the Java extension is installed. The launch tool checks + * Java and No-Config readiness at invocation without waiting for initialization. */ function registerLanguageModelTools(context: vscode.ExtensionContext, noConfigDebug: NoConfigDebugRegistration): void { // Check if Language Model API is available 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 55b95c65..29f848db 100644 --- a/src/languageModelTool.ts +++ b/src/languageModelTool.ts @@ -5,7 +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 { NoConfigDebugRegistration } from "./noConfigDebugInit"; +import { JavaServerReadiness, JavaServerReadinessState, observeJavaServerReadiness } from "./javaServerReadiness"; +import { NoConfigDebugRegistration, NoConfigDebugState } from "./noConfigDebugInit"; import { beginDebugSessionInvocation, classifyBreakpoint, @@ -110,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, - noConfigDebug: Pick, + noConfigDebug: Pick, ): vscode.Disposable | undefined { // Check if the Language Model API is available const lmApi = (vscode as any).lm; @@ -125,34 +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 { - const readiness = await noConfigDebug.waitUntilReady(token); - if (readiness.status !== "ready") { - let message: string; - switch (readiness.status) { - case "disabled": - message = `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."; - break; - case "failed": - message = `${readiness.message} This tool cannot launch until initialization succeeds. ` - + "Resolve the initialization problem and reload VS Code before retrying."; - break; - case "cancelled": - message = "Operation cancelled by user while waiting for Java No-Config Debug initialization."; - break; - case "timeout": - message = "Timed out waiting for Java No-Config Debug initialization. " - + "Initialization is still running; you can retry this tool later."; - break; - case "disposed": - message = "Java No-Config Debug has been disposed. Reload VS Code before retrying this tool."; - break; - } + 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( - `${message} Standard Java launch/attach debugging remains available.`, + `${readinessMessage} No build, terminal, or debug session changes were made.`, ), ]); } @@ -230,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 b91d1fe6..89cd4cbe 100644 --- a/src/noConfigDebugInit.ts +++ b/src/noConfigDebugInit.ts @@ -16,11 +16,12 @@ export type NoConfigDebugResult = | { status: "ready" | "disabled" | "disposed" } | { status: "failed"; message: string }; -export type NoConfigDebugWaitResult = NoConfigDebugResult | { status: "cancelled" | "timeout" }; +export type NoConfigDebugState = NoConfigDebugResult | { status: "initializing" }; export interface NoConfigDebugRegistration extends vscode.Disposable { + // Terminal preparation is independent of JDT LS server readiness. readonly ready: Promise; - waitUntilReady(token: vscode.CancellationToken, timeoutMs?: number): Promise; + getState(): NoConfigDebugState; } interface InitializationLifetime { @@ -92,8 +93,13 @@ export function registerNoConfigDebug( ): NoConfigDebugRegistration { const cancellation = new vscode.CancellationTokenSource(); const lifetime: InitializationLifetime = { token: cancellation.token, disposables: [] }; - let complete!: (result: NoConfigDebugResult) => void; - const ready = new Promise((resolve) => { complete = resolve; }); + 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(); @@ -122,31 +128,7 @@ export function registerNoConfigDebug( return { ready, - async waitUntilReady(token, timeoutMs = 60000): Promise { - if (token.isCancellationRequested) { - return { status: "cancelled" }; - } - let listener: vscode.Disposable | undefined; - let timeout: NodeJS.Timeout | undefined; - try { - const result = await Promise.race([ - ready, - new Promise((resolve) => { - listener = token.onCancellationRequested(() => resolve({ status: "cancelled" })); - timeout = setTimeout(() => resolve({ status: "timeout" }), timeoutMs); - }), - ]); - if (token.isCancellationRequested) { - return { status: "cancelled" }; - } - return lifetime.token.isCancellationRequested ? { status: "disposed" } : result; - } finally { - listener?.dispose(); - if (timeout) { - clearTimeout(timeout); - } - } - }, + getState: () => state, dispose() { if (lifetime.token.isCancellationRequested) { return; 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 index 43c2fe9f..17d4fa5a 100644 --- a/test/helpers/deferred.ts +++ b/test/helpers/deferred.ts @@ -10,3 +10,19 @@ export function deferred() { }); 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 index b1a89215..bc7b4c50 100644 --- a/test/noConfigDebugActivation.test.ts +++ b/test/noConfigDebugActivation.test.ts @@ -12,7 +12,7 @@ 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 } from "./helpers/deferred"; +import { deferred, withinDeadline } from "./helpers/deferred"; import { createFakeCollection } from "./helpers/environmentVariableCollection"; suite("No-Config Debug activation", () => { @@ -56,8 +56,8 @@ suite("No-Config Debug activation", () => { events.push(`no-config:${result.status}`); return result; }), - async waitUntilReady() { - assert.fail("Activation must not wait for No-Config Debug readiness"); + getState() { + return { status: testCase.enabled ? "initializing" : "disabled" }; }, dispose() { if (!disposed) { @@ -186,22 +186,6 @@ suite("No-Config Debug activation", () => { } }); -async function withinDeadline(promise: Promise, message: string): Promise { - let timeout: NodeJS.Timeout | undefined; - try { - return await Promise.race([ - promise, - new Promise((_resolve, reject) => { - timeout = setTimeout(() => reject(new Error(message)), 500); - }), - ]); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -} - function createExtension(id: string, extensionPath: string): vscode.Extension { return { id, diff --git a/test/noConfigDebugSettings.test.ts b/test/noConfigDebugSettings.test.ts index c697ea15..9876e4ab 100644 --- a/test/noConfigDebugSettings.test.ts +++ b/test/noConfigDebugSettings.test.ts @@ -8,9 +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, NoConfigDebugWaitResult } from "../src/noConfigDebugInit"; -import { deferred } from "./helpers/deferred"; +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 () => { @@ -30,16 +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 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 waitedTokens: vscode.CancellationToken[]; + 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 telemetryCalls: 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 { @@ -49,21 +110,31 @@ suite("No-Config Debug AI startup readiness", () => { cleanups.push(() => Object.defineProperty(target, key, original)); } - function registerReadiness(result: NoConfigDebugWaitResult | Promise): vscode.LanguageModelTool { - const readiness: Pick = { - async waitUntilReady(token) { - waitedTokens.push(token); - return result; + 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); - cleanups.push(() => disposable.dispose()); + 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 registeredTool; + return { tool: registeredTool, disposable }; } function resultText(result: unknown): string { @@ -74,52 +145,138 @@ suite("No-Config Debug AI startup readiness", () => { return text.value; } - function assertWaitedWithInvocationToken(): void { - assert.strictEqual(waitedTokens.length, 1); - assert.strictEqual(waitedTokens[0], cancellation.token); - } - function assertNoLaunchWork(): void { assert.strictEqual(inputReads, 0); - assert.strictEqual(telemetryCalls, 0); + assert.strictEqual(targetReads, 0); + assert.strictEqual(javaVersionProbes, 0); + assert.strictEqual(launchTelemetry.length, 0); assert.strictEqual(sideEffects, 0); } - async function invokeBlockedTool(readiness: NoConfigDebugWaitResult): Promise { - const tool = registerReadiness(readiness); - const result = await tool.invoke({ + 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, - }, cancellation.token); - assertWaitedWithInvocationToken(); + }); assertNoLaunchWork(); - const text = resultText(result); - assert.ok(text.includes("Standard Java launch/attach debugging remains available")); + 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(); - cleanups.push(() => cancellation.dispose()); - waitedTokens = []; + 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; - telemetryCalls = 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 }); - const recordTelemetry = () => { telemetryCalls += 1; }; - overrideProperty(telemetry, "sendInfo", { value: recordTelemetry }); - overrideProperty(telemetry, "sendError", { value: recordTelemetry }); + 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 before readiness or after cancellation"); @@ -130,110 +287,197 @@ suite("No-Config Debug AI startup readiness", () => { 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 disabled snapshot guidance without rereading settings, inspecting inputs, or doing launch work", 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); + }); + + 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 text = await invokeBlockedTool({ status: "disabled" }); - assert.ok(text.includes(ENABLE_NO_CONFIG_DEBUG)); - assert.ok(text.includes("enable that setting")); - assert.ok(text.includes("reload VS Code")); - assert.ok(text.includes("recreate existing terminals")); + 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("waits for pending readiness before inspecting inputs, emitting telemetry, or doing launch work", async () => { - const readiness = deferred(); - cleanups.push(() => readiness.resolve({ status: "disposed" })); - const tool = registerReadiness(readiness.promise); - let targetReads = 0; - const input = { - get target(): string { - targetReads += 1; - return "Main"; - }, - workspacePath: "unused", - }; - const invocation = Promise.resolve(tool.invoke({ - get input() { - inputReads += 1; - return input; - }, - toolInvocationToken: undefined, - }, cancellation.token)); - let settled = false; - void invocation.then(() => { settled = true; }, () => { settled = true; }); - await Promise.resolve(); + 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(); - assertWaitedWithInvocationToken(); - assert.strictEqual(settled, false); - assert.strictEqual(targetReads, 0); + 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); + }); - cancellation.cancel(); - readiness.resolve({ status: "ready" }); - const text = resultText(await invocation); - assert.ok(text.includes("Operation cancelled by user")); - assert.ok(inputReads > 0); - assert.ok(targetReads > 0); - assert.ok(telemetryCalls > 0); - assert.strictEqual(sideEffects, 0); + 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 initialization error and recovery guidance without doing launch work", async () => { - const message = "Java No-Config Debug initialization failed (EACCES)."; - const text = await invokeBlockedTool({ status: "failed", message }); - assert.ok(text.includes(message)); - assert.ok(text.includes("cannot launch until initialization succeeds")); - assert.ok(text.includes("Resolve the initialization problem and reload VS Code")); - assert.strictEqual(text.includes("enable that setting"), false); + 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("returns cancellation while waiting without inspecting inputs or doing launch work", async () => { - cancellation.cancel(); - const text = await invokeBlockedTool({ status: "cancelled" }); - assert.ok(text.includes("Operation cancelled by user while waiting")); - assert.strictEqual(text.includes("enable that setting"), false); + 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); }); - test("returns retry guidance on readiness timeout without doing launch work", async () => { - const text = await invokeBlockedTool({ status: "timeout" }); - assert.ok(text.includes("Timed out waiting for Java No-Config Debug initialization")); - assert.ok(text.includes("Initialization is still running")); - assert.ok(text.includes("retry this tool later")); - assert.strictEqual(text.includes("enable that setting"), false); + 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(); + const readsBefore = [noConfigStateReads, javaStateReads]; + + assert.strictEqual(await invokeBlockedTool(tool), "CANCELLED: Operation cancelled by user." + noChanges); + assert.deepStrictEqual([noConfigStateReads, javaStateReads], readsBefore); }); - test("returns reload guidance when initialization is disposed without doing launch work", async () => { - const text = await invokeBlockedTool({ status: "disposed" }); - assert.ok(text.includes("has been disposed")); - assert.ok(text.includes("Reload VS Code before retrying this tool")); - assert.strictEqual(text.includes("enable that setting"), false); + 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("continues the existing launch flow when readiness succeeds", async () => { - const tool = registerReadiness({ status: "ready" }); - cancellation.cancel(); + 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(); - const result = await tool.invoke({ - input: { target: "Main", workspacePath: "unused" }, - toolInvocationToken: undefined, - }, cancellation.token); - assertWaitedWithInvocationToken(); - const text = resultText(result); - assert.ok(text.includes("Operation cancelled by user")); - assert.strictEqual(text.includes(ENABLE_NO_CONFIG_DEBUG), false); - assert.ok(telemetryCalls > 0); - assert.strictEqual(sideEffects, 0); + 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 971917a2..05e9eac5 100644 --- a/test/noConfigDebugStorage.test.ts +++ b/test/noConfigDebugStorage.test.ts @@ -153,6 +153,7 @@ suite("No-Config Debug workspace storage", () => { test("does not report a missing workspace when explicitly disabled", async () => { 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); @@ -320,70 +321,47 @@ suite("No-Config Debug workspace storage", () => { assert.strictEqual(warnings.length, 0); }); - test("shares readiness and lets one caller cancel without cancelling initialization", async () => { + 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(); - const first = new vscode.CancellationTokenSource(); - const second = new vscode.CancellationTokenSource(); - cleanups.push(() => first.dispose(), () => second.dispose()); + assert.deepStrictEqual(registration.getState(), { status: "initializing" }); try { await requested.promise; - let ready = false; - const pending = registration.waitUntilReady(second.token).then((result) => { - ready = true; - return result; - }); - const cancelled = registration.waitUntilReady(first.token); - first.cancel(); - assert.deepStrictEqual(await cancelled, { status: "cancelled" }); - assert.strictEqual(ready, false); + 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 pending, { status: "ready" }); 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("bounds each wait and allows retrying the same initialization after timeout", async () => { - const javaHome = deferred(); - replaceProperty(utility, "getJavaHome", () => javaHome.promise); - const registration = startRegistration(); - const caller = new vscode.CancellationTokenSource(); - cleanups.push(() => caller.dispose()); - try { - assert.deepStrictEqual(await registration.waitUntilReady(caller.token, 10), { status: "timeout" }); - javaHome.resolve(path.join(tempDir, "jdk")); - assert.deepStrictEqual(await registration.waitUntilReady(caller.token), { status: "ready" }); - assert.strictEqual(patterns.length, 1); - } finally { - javaHome.resolve(""); - await registration.ready; - } - }); - - test("returns immediately for an already cancelled caller", async () => { + test("reports disposal immediately while directory setup remains pending", async () => { const directory = deferred(); replaceProperty(fs.promises, "mkdir", () => directory.promise); const registration = startRegistration(); - const caller = new vscode.CancellationTokenSource(); - cleanups.push(() => caller.dispose()); - caller.cancel(); - assert.deepStrictEqual(await registration.waitUntilReady(caller.token), { status: "cancelled" }); + 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); }); @@ -399,6 +377,7 @@ suite("No-Config Debug workspace storage", () => { 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); @@ -420,14 +399,12 @@ suite("No-Config Debug workspace storage", () => { replaceProperty(vscode.debug, "onDidTerminateDebugSession", () => new vscode.Disposable(() => { sessionListenerDisposed = true; })); const registration = startRegistration(); - const caller = new vscode.CancellationTokenSource(); - cleanups.push(() => caller.dispose()); - const waiting = registration.waitUntilReady(caller.token); await requested.promise; registration.dispose(); assert.strictEqual(watcherDisposed, true); assert.strictEqual(sessionListenerDisposed, true); - assert.deepStrictEqual(await waiting, { status: "disposed" }); + 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")); @@ -453,6 +430,7 @@ suite("No-Config Debug workspace storage", () => { 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); }); @@ -506,9 +484,8 @@ suite("No-Config Debug workspace storage", () => { assert.strictEqual(attachCalls, 0); assert.strictEqual(fs.existsSync(endpoint), true); assert.strictEqual(errors.length, 0); - const caller = new vscode.CancellationTokenSource(); - cleanups.push(() => caller.dispose()); - assert.deepStrictEqual(await registration.waitUntilReady(caller.token), { status: "disposed" }); + assert.deepStrictEqual(registration.getState(), { status: "disposed" }); + assert.deepStrictEqual(await registration.ready, { status: "ready" }); }); for (const eventType of ["create", "change"]) {