From 55862237b4f06e3dd5292391afd24ba65e1ce915 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Tue, 8 Sep 2026 17:11:11 +0800 Subject: [PATCH] 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);