diff --git a/bundled/scripts/noConfigScripts/README.md b/bundled/scripts/noConfigScripts/README.md new file mode 100644 index 00000000..021f0553 --- /dev/null +++ b/bundled/scripts/noConfigScripts/README.md @@ -0,0 +1,102 @@ +# Java No-Config Debug + +This feature enables configuration-less debugging for Java applications, similar to the JavaScript Debug Terminal in VS Code. + +## How It Works + +When you open a terminal in VS Code with this extension installed, the following environment variables are automatically set: + +- `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. + +## Usage + +### Basic Usage + +Instead of running: +```bash +java -cp . com.example.Main +``` + +Simply run: +```bash +debugjava -cp . com.example.Main +``` + +The debugger will automatically attach, and breakpoints will work without any launch.json configuration! + +### Maven Projects + +```bash +debugjava -jar target/myapp.jar +``` + +### Gradle Projects + +```bash +debugjava -jar build/libs/myapp.jar +``` + +### With Arguments + +```bash +debugjava -cp . com.example.Main arg1 arg2 --flag=value +``` + +### Spring Boot + +```bash +debugjava -jar myapp.jar --spring.profiles.active=dev +``` + +## Advantages + +1. **No Configuration Required**: No need to create or maintain launch.json +2. **Rapid Prototyping**: Perfect for quick debugging sessions +3. **Script Debugging**: Debug applications launched by complex shell scripts +4. **Environment Consistency**: Inherits all terminal environment variables +5. **Parameter Flexibility**: Easy to change arguments using terminal history (↑ key) + +## How It Works Internally + +1. When you run `debugjava`, the wrapper script temporarily sets `JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=0` +2. The wrapper determines which Java executable to use (priority order): + - First: `JAVA_HOME/bin/java` if JAVA_HOME environment variable is set (user's explicit choice) + - Second: `VSCODE_JAVA_EXEC` environment variable (Java path from VS Code's Java Language Server) + - Third: `java` command from system PATH +3. The wrapper launches the Java process with JDWP enabled +4. JVM starts and outputs: "Listening for transport dt_socket at address: 12345" +5. The wrapper captures the JDWP port from this output +6. The port is written to a communication file +7. VS Code's file watcher detects the file and automatically starts an attach debug session + +## Troubleshooting + +### Port Already in Use + +If you see "Address already in use", another Java debug session is running. Terminate it first. + +### No Breakpoints Hit + +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 +4. Check the Debug Console for error messages + +### Node.js Not Found + +The wrapper script requires Node.js to be installed and available in PATH. + +## Limitations + +- Requires Node.js to be installed and available in PATH +- Only works in terminals opened within VS Code +- Requires using the `debugjava` command instead of `java` +- The Java process will suspend (hang) until the debugger attaches + +## See Also + +- [Debugger for Java Documentation](https://github.com/microsoft/vscode-java-debug) +- [JDWP Documentation](https://docs.oracle.com/javase/8/docs/technotes/guides/jpda/jdwp-spec.html) diff --git a/bundled/scripts/noConfigScripts/debugjava b/bundled/scripts/noConfigScripts/debugjava new file mode 100644 index 00000000..e0861d16 --- /dev/null +++ b/bundled/scripts/noConfigScripts/debugjava @@ -0,0 +1,16 @@ +#!/bin/bash +# Java No-Config Debug Wrapper Script for Unix/Linux/macOS +# This script intercepts java commands and automatically enables JDWP debugging + +# Export the endpoint file path for JDWP port communication +export JDWP_ADAPTER_ENDPOINTS=$VSCODE_JDWP_ADAPTER_ENDPOINTS + +# Set JDWP options only for this debugjava invocation +# This overrides the global JAVA_TOOL_OPTIONS to avoid affecting other Java processes +export JAVA_TOOL_OPTIONS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=0" + +# Get the directory of this script +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Use Node.js wrapper to capture JDWP port +exec node "$SCRIPT_DIR/jdwp-wrapper.js" "$@" diff --git a/bundled/scripts/noConfigScripts/debugjava.bat b/bundled/scripts/noConfigScripts/debugjava.bat new file mode 100644 index 00000000..828c656f --- /dev/null +++ b/bundled/scripts/noConfigScripts/debugjava.bat @@ -0,0 +1,13 @@ +@echo off +REM Java No-Config Debug Wrapper Script for Windows +REM This script intercepts java commands and automatically enables JDWP debugging + +REM Export the endpoint file path for JDWP port communication +set JDWP_ADAPTER_ENDPOINTS=%VSCODE_JDWP_ADAPTER_ENDPOINTS% + +REM Set JDWP options only for this debugjava invocation +REM This overrides the global JAVA_TOOL_OPTIONS to avoid affecting other Java processes +set JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=0 + +REM Use Node.js wrapper to capture JDWP port +node "%~dp0jdwp-wrapper.js" %* diff --git a/bundled/scripts/noConfigScripts/debugjava.fish b/bundled/scripts/noConfigScripts/debugjava.fish new file mode 100644 index 00000000..b745bc18 --- /dev/null +++ b/bundled/scripts/noConfigScripts/debugjava.fish @@ -0,0 +1,16 @@ +#!/usr/bin/env fish +# Java No-Config Debug Wrapper Script for Fish Shell +# This script intercepts java commands and automatically enables JDWP debugging + +# Export the endpoint file path for JDWP port communication +set -x JDWP_ADAPTER_ENDPOINTS $VSCODE_JDWP_ADAPTER_ENDPOINTS + +# Set JDWP options only for this debugjava invocation +# This overrides the global JAVA_TOOL_OPTIONS to avoid affecting other Java processes +set -x JAVA_TOOL_OPTIONS "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=0" + +# Get the directory of this script +set script_dir (dirname (status -f)) + +# Use Node.js wrapper to capture JDWP port +exec node "$script_dir/jdwp-wrapper.js" $argv diff --git a/bundled/scripts/noConfigScripts/debugjava.ps1 b/bundled/scripts/noConfigScripts/debugjava.ps1 new file mode 100644 index 00000000..931c0cad --- /dev/null +++ b/bundled/scripts/noConfigScripts/debugjava.ps1 @@ -0,0 +1,15 @@ +# Java No-Config Debug Wrapper Script for PowerShell +# This script intercepts java commands and automatically enables JDWP debugging + +# Export the endpoint file path for JDWP port communication +$env:JDWP_ADAPTER_ENDPOINTS = $env:VSCODE_JDWP_ADAPTER_ENDPOINTS + +# Set JDWP options only for this debugjava invocation +# This overrides the global JAVA_TOOL_OPTIONS to avoid affecting other Java processes +$env:JAVA_TOOL_OPTIONS = "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=0" + +# Get the directory of this script +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +# Use Node.js wrapper to capture JDWP port +& node (Join-Path $scriptDir "jdwp-wrapper.js") $args diff --git a/bundled/scripts/noConfigScripts/jdwp-wrapper.js b/bundled/scripts/noConfigScripts/jdwp-wrapper.js new file mode 100644 index 00000000..de8a4d92 --- /dev/null +++ b/bundled/scripts/noConfigScripts/jdwp-wrapper.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * JDWP Port Listener and Communication Wrapper + * + * This script wraps Java process execution and captures the JDWP port + * from the JVM output, then writes it to the endpoint file for VS Code + * to pick up and attach the debugger. + * + * JDWP Output Format: + * "Listening for transport dt_socket at address: 12345" + */ + +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +// Get environment variables +const endpointFile = process.env.JDWP_ADAPTER_ENDPOINTS || process.env.VSCODE_JDWP_ADAPTER_ENDPOINTS; +const javaToolOptions = process.env.JAVA_TOOL_OPTIONS || ''; + +// Check if debugging is enabled +const isDebugEnabled = javaToolOptions.includes('jdwp') && endpointFile; + +// Helper function to find java command +function getJavaCommand() { + // Priority 1: Try JAVA_HOME environment variable first (user's explicit choice) + const javaHome = process.env.JAVA_HOME; + if (javaHome) { + const javaPath = path.join(javaHome, 'bin', 'java'); + const javaPathExe = process.platform === 'win32' ? `${javaPath}.exe` : javaPath; + + // Check if the file exists + if (fs.existsSync(javaPathExe)) { + return javaPath; + } + if (fs.existsSync(javaPath)) { + return javaPath; + } + + console.warn(`[Java Debug] JAVA_HOME is set to '${javaHome}', but java command not found there. Falling back to VS Code's Java.`); + } + + // Priority 2: Use VSCODE_JAVA_EXEC if provided by VS Code (from Java Language Server) + const vscodeJavaExec = process.env.VSCODE_JAVA_EXEC; + if (vscodeJavaExec && fs.existsSync(vscodeJavaExec)) { + return vscodeJavaExec; + } + + // Priority 3: Fall back to 'java' in PATH + return 'java'; +} + +const javaCmd = getJavaCommand(); + +if (!isDebugEnabled) { + // No debugging, just run java normally + const child = spawn(javaCmd, process.argv.slice(2), { + stdio: 'inherit', + shell: false + }); + child.on('exit', (code) => process.exit(code || 0)); + child.on('error', (err) => { + console.error(`[Java Debug] Failed to start java: ${err.message}`); + console.error(`[Java Debug] Make sure Java is installed and either JAVA_HOME is set correctly or 'java' is in your PATH.`); + process.exit(1); + }); +} else { + // Debugging enabled, capture JDWP port + const child = spawn(javaCmd, process.argv.slice(2), { + stdio: ['inherit', 'pipe', 'pipe'], + shell: false + }); + + let portCaptured = false; + const jdwpPortRegex = /Listening for transport dt_socket at address:\s*(\d+)/; + + // Shared function to capture JDWP port from output + const capturePort = (output) => { + if (portCaptured) return; + + const match = output.match(jdwpPortRegex); + if (match && match[1]) { + const port = parseInt(match[1], 10); + + // Validate port range + if (port < 1 || port > 65535) { + console.error(`[Java Debug] Invalid port number: ${port}`); + return; + } + + console.log(`[Java Debug] Captured JDWP port: ${port}`); + + // Write port to endpoint file + const endpointData = JSON.stringify({ + client: { + host: 'localhost', + port: port + } + }); + + try { + fs.writeFileSync(endpointFile, endpointData, 'utf8'); + console.log(`[Java Debug] Wrote endpoint file: ${endpointFile}`); + portCaptured = true; + } catch (err) { + console.error(`[Java Debug] Failed to write endpoint file: ${err}`); + } + } + }; + + // Monitor stdout for JDWP port + child.stdout.on('data', (data) => { + const output = data.toString(); + process.stdout.write(data); + capturePort(output); + }); + + // Monitor stderr for JDWP port (it might appear on stderr) + child.stderr.on('data', (data) => { + const output = data.toString(); + process.stderr.write(data); + capturePort(output); + }); + + child.on('exit', (code) => process.exit(code || 0)); + child.on('error', (err) => { + console.error(`[Java Debug] Failed to start java: ${err}`); + process.exit(1); + }); +} diff --git a/src/extension.ts b/src/extension.ts index 6d8c2623..867b2c02 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,6 +13,7 @@ import { HCR_EVENT, JAVA_LANGID, TELEMETRY_EVENT, USER_NOTIFICATION_EVENT } from import { NotificationBar } from "./customWidget"; import { initializeCodeLensProvider, startDebugging } from "./debugCodeLensProvider"; import { initExpService } from "./experimentationService"; +import { registerNoConfigDebug } from "./noConfigDebugInit"; import { handleHotCodeReplaceCustomEvent, initializeHotCodeReplace, NO_BUTTON, YES_BUTTON } from "./hotCodeReplace"; import { JavaDebugAdapterDescriptorFactory } from "./javaDebugAdapterDescriptorFactory"; import { JavaInlineValuesProvider } from "./JavaInlineValueProvider"; @@ -31,6 +32,14 @@ import { promisify } from "util"; export async function activate(context: vscode.ExtensionContext): Promise { await initializeFromJsonFile(context.asAbsolutePath("./package.json")); await initExpService(context); + + // Register No-Config Debug functionality + const noConfigDisposable = await registerNoConfigDebug( + context.environmentVariableCollection, + context.extensionPath + ); + context.subscriptions.push(noConfigDisposable); + return instrumentOperation("activation", initializeExtension)(context); } diff --git a/src/noConfigDebugInit.ts b/src/noConfigDebugInit.ts new file mode 100644 index 00000000..b0da4f01 --- /dev/null +++ b/src/noConfigDebugInit.ts @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as vscode from 'vscode'; + +import { sendInfo, sendError } from "vscode-extension-telemetry-wrapper"; +import { getJavaHome } from "./utility"; + +/** + * Registers the configuration-less debugging setup for the extension. + * + * This function sets up environment variables and a file system watcher to + * facilitate debugging without requiring a pre-configured launch.json file. + * + * @param envVarCollection - The collection of environment variables to be modified. + * @param extPath - The path to the extension directory. + * + * Environment Variables: + * - `VSCODE_JDWP_ADAPTER_ENDPOINTS`: Path to the file containing the debugger adapter endpoint. + * - `JAVA_TOOL_OPTIONS`: JDWP configuration for automatic debugging. + * - `PATH`: Appends the path to the noConfigScripts directory. + */ +export async function registerNoConfigDebug( + envVarCollection: vscode.EnvironmentVariableCollection, + extPath: string, +): Promise { + const collection = envVarCollection; + + // create a temp directory for the noConfigDebugAdapterEndpoints + // file path format: extPath/.noConfigDebugAdapterEndpoints/endpoint-stableWorkspaceHash.txt + let workspaceString = vscode.workspace.workspaceFile?.fsPath; + if (!workspaceString) { + workspaceString = vscode.workspace.workspaceFolders?.map((e) => e.uri.fsPath).join(';'); + } + if (!workspaceString) { + const error: Error = { + name: "NoConfigDebugError", + message: '[Java Debug] No workspace folder found', + }; + sendError(error); + return Promise.resolve(new vscode.Disposable(() => { })); + } + + // create a stable hash for the workspace folder, reduce terminal variable churn + const hash = crypto.createHash('sha256'); + hash.update(workspaceString.toString()); + const stableWorkspaceHash = hash.digest('hex').slice(0, 16); + + const tempDirPath = path.join(extPath, '.noConfigDebugAdapterEndpoints'); + const tempFilePath = path.join(tempDirPath, `endpoint-${stableWorkspaceHash}.txt`); + + // create the temp directory if it doesn't exist + if (!fs.existsSync(tempDirPath)) { + fs.mkdirSync(tempDirPath, { recursive: true }); + } else { + // remove endpoint file in the temp directory if it exists (async to avoid blocking) + if (fs.existsSync(tempFilePath)) { + fs.promises.unlink(tempFilePath).catch((err) => { + const error: Error = { + name: "NoConfigDebugError", + message: `[Java Debug] Failed to cleanup old endpoint file: ${err}`, + }; + sendError(error); + }); + } + } + + // clear the env var collection to remove any existing env vars + collection.clear(); + + // Add env var for VSCODE_JDWP_ADAPTER_ENDPOINTS + // Note: We do NOT set JAVA_TOOL_OPTIONS globally to avoid affecting all Java processes + // (javac, maven, gradle, language server, etc.). Instead, JAVA_TOOL_OPTIONS is set + // only in the debugjava wrapper scripts (debugjava.ps1, debugjava.bat, debugjava) + collection.replace('VSCODE_JDWP_ADAPTER_ENDPOINTS', tempFilePath); + + // Try to get Java executable from Java Language Server + // This ensures we use the same Java version as the project is compiled with + try { + const javaHome = await getJavaHome(); + if (javaHome) { + const javaExec = path.join(javaHome, 'bin', 'java'); + collection.replace('VSCODE_JAVA_EXEC', javaExec); + } + } catch (error) { + // If we can't get Java from Language Server, that's okay + // The wrapper script will fall back to JAVA_HOME or PATH + } + + const noConfigScriptsDir = path.join(extPath, 'bundled', 'scripts', 'noConfigScripts'); + const pathSeparator = process.platform === 'win32' ? ';' : ':'; + + // Check if the current PATH already ends with a path separator to avoid double separators + const currentPath = process.env.PATH || ''; + const needsSeparator = currentPath.length > 0 && !currentPath.endsWith(pathSeparator); + const pathValueToAppend = needsSeparator ? `${pathSeparator}${noConfigScriptsDir}` : noConfigScriptsDir; + + collection.append('PATH', pathValueToAppend); + + // create file system watcher for the debuggerAdapterEndpointFolder for when the communication port is written + const fileSystemWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(tempDirPath, '**/*.txt') + ); + + // Track active debug sessions to prevent duplicates + const activeDebugSessions = new Set(); + + // Handle both file creation and modification to support multiple runs + const handleEndpointFile = async (uri: vscode.Uri) => { + const filePath = uri.fsPath; + + // 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)); + + fs.readFile(filePath, (err, data) => { + if (err) { + const error: Error = { + name: "NoConfigDebugError", + message: `[Java Debug] No-config debug failed: file_read_error - ${err}`, + }; + sendError(error); + return; + } + try { + // parse the client port + const dataParse = data.toString(); + const jsonData = JSON.parse(dataParse); + + // Validate JSON structure + if (!jsonData || typeof jsonData !== 'object' || !jsonData.client) { + const error: Error = { + name: "NoConfigDebugError", + message: `[Java Debug] No-config debug failed: invalid_format - ${dataParse}`, + }; + sendError(error); + return; + } + + const clientPort = jsonData.client.port; + + // Validate port number + if (!clientPort || typeof clientPort !== 'number' || clientPort < 1 || clientPort > 65535) { + const error: Error = { + name: "NoConfigDebugError", + message: `[Java Debug] No-config debug failed: invalid_port - ${clientPort}`, + }; + sendError(error); + return; + } + + // Check if we already have an active session for this port + if (activeDebugSessions.has(clientPort)) { + // Skip duplicate session silently - this is expected behavior + return; + } + + // Mark this port as active + activeDebugSessions.add(clientPort); + + const options: vscode.DebugSessionOptions = { + noDebug: false, + }; + + // start debug session with the client port + vscode.debug.startDebugging( + undefined, + { + type: 'java', + request: 'attach', + name: 'Attach to Java (No-Config)', + hostName: 'localhost', + port: clientPort, + }, + options, + ).then( + (started) => { + 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) => { + // Cleanup failure is non-critical, just log for debugging + const error: Error = { + name: "NoConfigDebugError", + message: `[Java Debug] No-config debug failed: cleanup_error - ${cleanupErr}`, + }; + sendError(error); + }); + } + } else { + const error: Error = { + name: "NoConfigDebugError", + message: `[Java Debug] No-config debug failed: attach_failed - port ${clientPort}`, + }; + sendError(error); + // Remove from active sessions on failure + activeDebugSessions.delete(clientPort); + } + }, + (error) => { + const attachError: Error = { + name: "NoConfigDebugError", + message: `[Java Debug] No-config debug failed: attach_error - port ${clientPort} - ${error}`, + }; + sendError(attachError); + // Remove from active sessions on error + activeDebugSessions.delete(clientPort); + }, + ); + } catch (parseErr) { + const error: Error = { + name: "NoConfigDebugError", + message: `[Java Debug] No-config debug failed: parse_error - ${parseErr}`, + }; + sendError(error); + } + }); + }; + + // Listen for both file creation and modification events + const fileCreationEvent = fileSystemWatcher.onDidCreate(handleEndpointFile); + const fileChangeEvent = fileSystemWatcher.onDidChange(handleEndpointFile); + + // Clean up active sessions when debug session ends + const debugSessionEndListener = 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 + } + }); + + return Promise.resolve( + new vscode.Disposable(() => { + fileSystemWatcher.dispose(); + fileCreationEvent.dispose(); + fileChangeEvent.dispose(); + debugSessionEndListener.dispose(); + activeDebugSessions.clear(); + }), + ); +}