From 2e293f67bc13d78abf1c3e30838cb5f1fdc26394 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 4 Dec 2025 00:09:56 +0100 Subject: [PATCH 01/14] native extension-debugger --- .gitignore | 5 +- DAP_TEST_CLIENT_PLAN.md | 98 +++++ NATIVE_DEBUGGING_PLAN.md | 179 ++++++++ extension/META-INF/MANIFEST.MF | 10 + luceedebug/build.gradle.kts | 24 ++ .../src/main/java/luceedebug/Agent.java | 12 +- .../src/main/java/luceedebug/Config.java | 2 +- .../src/main/java/luceedebug/DapServer.java | 48 ++- .../src/main/java/luceedebug/ILuceeVm.java | 36 +- .../java/luceedebug/LuceeTransformer.java | 66 +++ .../src/main/java/luceedebug/ThreadInfo.java | 15 + .../coreinject/CfValueDebuggerBridge.java | 10 + .../luceedebug/coreinject/DebugManager.java | 45 +- .../java/luceedebug/coreinject/LuceeVm.java | 96 ++++- .../coreinject/NativeDebuggerListener.java | 408 ++++++++++++++++++ .../luceedebug/coreinject/NativeLuceeVm.java | 265 ++++++++++++ .../coreinject/frame/NativeDebugFrame.java | 291 +++++++++++++ .../extension/ExtensionActivator.java | 146 +++++++ profiling/DapClient.cfc | 338 +++++++++++++++ profiling/NATIVE_DEBUGGING_TESTING.md | 178 ++++++++ profiling/README.md | 223 ++++++++++ profiling/benchmark.cfm | 115 +++++ profiling/compare-results.bat | 55 +++ profiling/dap-step-target.cfm | 24 ++ profiling/dap-target.cfm | 14 + profiling/profile-baseline-docs.bat | 34 ++ profiling/profile-baseline-spreadsheet.bat | 31 ++ profiling/profile-baseline.bat | 31 ++ profiling/profile-local-lucee.bat | 52 +++ profiling/profile-with-agent-docs.bat | 46 ++ profiling/profile-with-agent-spreadsheet.bat | 43 ++ profiling/profile-with-agent.bat | 43 ++ profiling/test-breakpoint-bif.bat | 25 ++ profiling/test-breakpoint-bif.cfm | 56 +++ profiling/test-dap-breakpoint.cfm | 145 +++++++ profiling/test-dap-stepping.bat | 47 ++ profiling/test-dap-stepping.cfm | 238 ++++++++++ profiling/test-debugger-frames.cfm | 68 +++ profiling/test-extension.bat | 45 ++ profiling/test-extension.cfm | 78 ++++ profiling/test-native-frames-with-agent.bat | 50 +++ profiling/test-native-frames.bat | 32 ++ 42 files changed, 3714 insertions(+), 53 deletions(-) create mode 100644 DAP_TEST_CLIENT_PLAN.md create mode 100644 NATIVE_DEBUGGING_PLAN.md create mode 100644 extension/META-INF/MANIFEST.MF create mode 100644 luceedebug/src/main/java/luceedebug/ThreadInfo.java create mode 100644 luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java create mode 100644 luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java create mode 100644 luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java create mode 100644 luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java create mode 100644 profiling/DapClient.cfc create mode 100644 profiling/NATIVE_DEBUGGING_TESTING.md create mode 100644 profiling/README.md create mode 100644 profiling/benchmark.cfm create mode 100644 profiling/compare-results.bat create mode 100644 profiling/dap-step-target.cfm create mode 100644 profiling/dap-target.cfm create mode 100644 profiling/profile-baseline-docs.bat create mode 100644 profiling/profile-baseline-spreadsheet.bat create mode 100644 profiling/profile-baseline.bat create mode 100644 profiling/profile-local-lucee.bat create mode 100644 profiling/profile-with-agent-docs.bat create mode 100644 profiling/profile-with-agent-spreadsheet.bat create mode 100644 profiling/profile-with-agent.bat create mode 100644 profiling/test-breakpoint-bif.bat create mode 100644 profiling/test-breakpoint-bif.cfm create mode 100644 profiling/test-dap-breakpoint.cfm create mode 100644 profiling/test-dap-stepping.bat create mode 100644 profiling/test-dap-stepping.cfm create mode 100644 profiling/test-debugger-frames.cfm create mode 100644 profiling/test-extension.bat create mode 100644 profiling/test-extension.cfm create mode 100644 profiling/test-native-frames-with-agent.bat create mode 100644 profiling/test-native-frames.bat diff --git a/.gitignore b/.gitignore index cc6b4af..0b8ccaf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,7 @@ build .metals .bloop generated/ -test/scratch \ No newline at end of file +test/scratch +/profiling/output +/test-output +/profiling/test-output diff --git a/DAP_TEST_CLIENT_PLAN.md b/DAP_TEST_CLIENT_PLAN.md new file mode 100644 index 0000000..c9d1d58 --- /dev/null +++ b/DAP_TEST_CLIENT_PLAN.md @@ -0,0 +1,98 @@ +# CFML DAP Client for Debugger Testing + +## Goal + +Create a pure CFML DAP client to test luceedebug without VS Code involvement. This enables automated testing and can be bundled with the extension for users. + +## Architecture + +``` +Instance A (test runner) Instance B (debuggee) +───────────────────────── ──────────────────────── +DapClient.cfc luceedebug + Lucee7 +test-*.cfm target scripts + │ │ + │ DAP socket ──────────────────> │ port 10000 + │ HTTP trigger ─────────────────> │ port 8888 +``` + +**Critical**: Test runner must NOT run with debugger enabled to avoid deadlock. + +## Files to Create + +### 1. `profiling/DapClient.cfc` + +DAP protocol client with: + +- **Connection**: TCP socket via `java.net.Socket` +- **Framing**: `Content-Length: N\r\n\r\n{json}` +- **Sequence tracking**: Auto-increment request seq +- **Response/event handling**: Separate queues for responses vs events + +Methods: + +- `connect( host, port )` / `disconnect()` +- `initialize()` - DAP initialize handshake +- `setBreakpoints( path, lines[] )` - Set breakpoints for file +- `configurationDone()` - Signal ready to run +- `continue( threadId )` / `continueAll()` +- `stackTrace( threadId )` - Get stack frames +- `scopes( frameId )` - Get variable scopes +- `variables( variablesReference )` - Get variables +- `threads()` - List threads +- `waitForEvent( type, timeoutMs )` - Wait for specific event (stopped, thread, etc.) +- `stepOver( threadId )` / `stepIn( threadId )` / `stepOut( threadId )` + +### 2. `profiling/test-dap-breakpoint.cfm` + +Test native breakpoint flow: + +1. Connect to debuggee DAP port +2. Set breakpoint on target file +3. HTTP request to trigger target (async thread) +4. Wait for "stopped" event +5. Verify stack trace shows correct file/line +6. Continue and verify request completes + +### 3. `profiling/dap-target.cfm` + +Simple target script in debuggee webroot: + +```cfml +function testFunc( name ) { + var greeting = "Hello, #name#!"; // <- breakpoint here + return greeting; +} +result = testFunc( "World" ); +``` + +## DAP Protocol Notes + +Message format: + +``` +Content-Length: 119\r\n +\r\n +{"seq":1,"type":"request","command":"initialize","arguments":{"clientID":"cfml-test","adapterID":"luceedebug"}} +``` + +Key request/response pairs: + +- `initialize` -> capabilities +- `setBreakpoints` -> breakpoint[] with verified flag +- `configurationDone` -> empty +- `continue` -> empty +- `stackTrace` -> stackFrames[] +- `threads` -> threads[] + +Key events: + +- `stopped` - thread hit breakpoint (body.threadId, body.reason) +- `thread` - thread started/exited + +## Implementation Order + +1. DapClient.cfc - core protocol +2. Basic test - connect, initialize, disconnect +3. Breakpoint test - full flow with target script +4. Step test - stepOver/stepIn/stepOut (once Phase 2 native stepping is done) diff --git a/NATIVE_DEBUGGING_PLAN.md b/NATIVE_DEBUGGING_PLAN.md new file mode 100644 index 0000000..6b37dfb --- /dev/null +++ b/NATIVE_DEBUGGING_PLAN.md @@ -0,0 +1,179 @@ +# Native Lucee Debugging Plan + +Goal: Make debugging Lucee as easy as possible - install extension, connect VS Code, go. + +## Current State (done) + +- `DEBUGGER_ENABLED` env var/system property enables debug mode +- Native `DebuggerFrame` stack in PageContextImpl (push/pop on UDF calls) +- `ExecutionLog.start(pos, line, id)` passes line numbers from compiler +- `DebuggerExecutionLog` updates frame line numbers +- luceedebug can read native frames via reflection fallback (`NativeDebugFrame.java`) +- **Tested working**: luceedebug agent + Lucee7 with native frames (2025-12-03) + - Agent starts, connects to JDWP, DAP server listens on port 10000 + - Native frames show correct function names, file paths, local variables, arguments + - Test script: `profiling/test-native-frames-with-agent.bat` + +## Phase 1: Native Breakpoints ✅ DONE (Lucee core parts) + +### Lucee Core Changes - IMPLEMENTED + +1. ✅ **DebuggerListener interface** (`lucee.runtime.debug.DebuggerListener`) + ```java + public interface DebuggerListener { + void onSuspend(PageContext pc, String file, int line, String label); + void onResume(PageContext pc); + boolean hasBreakpoint(String file, int line); + } + ``` + +2. ✅ **DebuggerRegistry** (`lucee.runtime.debug.DebuggerRegistry`) + - Static singleton for listener registration + - `setListener(listener)` / `getListener()` / `hasListener()` + - luceedebug accesses via reflection + +3. ✅ **Breakpoint Check in DebuggerExecutionLog.start()** + ```java + public void start(int pos, int line, String id) { + DebuggerFrame frame = pci.getTopmostDebuggerFrame(); + if (frame != null) { + frame.setLine(line); + DebuggerListener listener = DebuggerRegistry.getListener(); + if (listener != null && listener.hasBreakpoint(frame.getFile(), line)) { + pci.debuggerSuspend(null); + } + } + } + ``` + +4. ✅ **Thread Suspension with callbacks** in PageContextImpl + - `debuggerSuspend(label)` calls `listener.onSuspend()` before blocking + - Calls `listener.onResume()` after unblocking + - Tracks `debuggerTotalSuspendedNanos` for timeout adjustment + +5. ⏳ **Timeout Pausing** - `debuggerTotalSuspendedNanos` tracked but not yet wired into timeout calc + +### luceedebug Changes - IMPLEMENTED + +1. ✅ **NativeDebuggerListener** class (`luceedebug.coreinject.NativeDebuggerListener`) + - Breakpoint map: `ConcurrentHashMap` for "file:line" -> true + - Tracks natively suspended threads: `ConcurrentHashMap>` + - `onSuspend()` / `onResume()` / `hasBreakpoint()` interface methods + - `resumeNativeThread()` calls `debuggerResume()` via reflection + +2. ✅ **Register via reflection** in `LuceeTransformer.registerNativeDebuggerListener()` + - Creates dynamic proxy implementing `DebuggerListener` + - Registers with `DebuggerRegistry.setListener()` + - Gracefully falls back if DebuggerRegistry not available (pre-Lucee7) + +3. ✅ **DAP stopped event** from `onSuspend` callback + - `LuceeVm.registerNativeBreakpointEventCallback()` receives Java thread ID + - `DapServer` sends `StoppedEventArguments` to VS Code + +4. ✅ **Continue support** for native threads + - `LuceeVm.continue_(long)` tries native resume first, falls back to JDWP + - `continueAll()` resumes both native and JDWP suspended threads + +5. ✅ **Wire breakpoints to NativeDebuggerListener** + - `bindBreakpoints()` clears and adds native breakpoints for file + - `clearAllBreakpoints()` clears native breakpoints + +6. ✅ **Native-only mode flag** (skip JDWP breakpoint registration) + - `NativeDebuggerListener.setNativeOnlyMode(true)` enables native-only mode + - When enabled, `bindBreakpoints()` skips JDWP registration, returns all breakpoints as "bound" + - `clearAllBreakpoints()` skips JDWP operations in native-only mode + - NOTE: This is for hybrid agent mode. True extension mode needs `NativeLuceeVm` (see below) + +7. ✅ **NativeLuceeVm** - ILuceeVm implementation without JDWP (stubbed) + - For true extension deployment (no agent, no JDWP, no bytecode instrumentation) + - Implements `ILuceeVm` interface using only native Lucee7 debugging APIs + - No `VirtualMachine` dependency + - Refactored `ILuceeVm` to remove JDWP types (`ThreadReference` -> `ThreadInfo`, `JdwpThreadID` -> `Long`) + - ✅ Thread listing via `CFMLFactoryImpl.getActivePageContexts()` (reflection) + - Stack frames via native `DebuggerFrame` stack (working) + - Variables via `CfValueDebuggerBridge` (working) + - ⏳ Native stepping - needs Phase 2 implementation + +## Phase 2: Stepping + +### Step Into +- Set flag `stepMode = STEP_INTO` +- Next `start()` call suspends + +### Step Over +- Record current frame depth +- Set flag `stepMode = STEP_OVER, stepDepth = currentDepth` +- Suspend when `start()` called at same or lower depth + +### Step Out +- Record current frame depth +- Set flag `stepMode = STEP_OUT, stepDepth = currentDepth` +- Suspend when frame depth < stepDepth + +```java +// In DebuggerExecutionLog.start() +if (stepMode == STEP_INTO) { + suspend(); +} else if (stepMode == STEP_OVER && currentDepth <= stepDepth) { + suspend(); +} else if (stepMode == STEP_OUT && currentDepth < stepDepth) { + suspend(); +} +``` + +## Phase 3: Conditional Breakpoints & Watches + +- Breakpoint conditions: evaluate CFML expression, break if truthy +- Watch expressions: evaluate on suspend, show in VS Code +- Both use existing `Evaluate.call()` infrastructure + +## Phase 4: Programmatic Breakpoints ✅ DONE + +BIF `breakpoint()` - like JavaScript's `debugger;` statement. + +```cfml +// Simple - pause here +breakpoint(); + +// Conditional - only pause when condition is true +breakpoint( user.isAdmin() ); + +// Labeled - shows in debugger UI +breakpoint( label="after auth check" ); +``` + +Implementation: +```java +public class Breakpoint extends BIF { + public static Object call(PageContext pc) { + return call(pc, true, null); + } + public static Object call(PageContext pc, boolean condition, String label) { + if (condition && PageContextImpl.DEBUGGER_ENABLED) { + ((PageContextImpl) pc).debuggerSuspend(label); + } + return null; + } +} +``` + +Benefits: +- Conditional breakpoints without IDE config +- Debug specific code paths +- Zero cost when DEBUGGER_ENABLED=false +- Works in production - flip flag, attach, hit code path + +## Open Questions + +1. ✅ **Loader interface changes** - RESOLVED: No loader changes needed. DebuggerListener/Registry in core, accessed via reflection. +2. **Extension loading order** - debugger extension needs to init early +3. ✅ **Multiple debuggers** - RESOLVED: Single listener only. Keep it simple. +4. **Remote debugging** - DAP over network vs localhost only? +5. **Performance** - hash lookup per line acceptable? Profile it. (hasBreakpoint called on every line) + +## Migration Path + +1. Ship Lucee 7.1 with native debug support +2. Ship luceedebug 2.0 as extension (no agent required) +3. Keep agent mode working for older Lucee versions +4. Deprecate agent mode eventually diff --git a/extension/META-INF/MANIFEST.MF b/extension/META-INF/MANIFEST.MF new file mode 100644 index 0000000..627b716 --- /dev/null +++ b/extension/META-INF/MANIFEST.MF @@ -0,0 +1,10 @@ +Manifest-Version: 1.0 +id: FA79A831-7D30-4D8A-B7F300DECEB00001 +name: "Luceedebug" +symbolic-name: "luceedebug" +description: "Native CFML debugger for VS Code - no Java agent required" +version: "3.0.0" +lucee-core-version: "7.1.0.0" +start-bundles: true +release-type: server +startup-hook: [{"class": "luceedebug.extension.ExtensionActivator", "bundle-name": "luceedebug-osgi", "bundle-version": "2.0.1.1"}] diff --git a/luceedebug/build.gradle.kts b/luceedebug/build.gradle.kts index 88d028f..30b4df5 100644 --- a/luceedebug/build.gradle.kts +++ b/luceedebug/build.gradle.kts @@ -117,3 +117,27 @@ tasks.shadowJar { relocationPrefix = "luceedebug_shadow" archiveFileName.set(libfile) } + +// Extension packaging task - creates .lex file for Lucee extension deployment +val extensionVersion = "3.0.0" +val extensionFile = "luceedebug-extension-${extensionVersion}.lex" + +tasks.register("buildExtension") { + dependsOn("shadowJar") + archiveFileName.set(extensionFile) + destinationDirectory.set(file("${layout.buildDirectory.get()}/extension")) + + // Include the shadow JAR as the main library + from(tasks.shadowJar.get().outputs) { + into("jars") + } + + // Include the extension manifest + from("${rootProject.projectDir}/extension/META-INF") { + into("META-INF") + } + + doLast { + println("Built extension: ${destinationDirectory.get()}/${extensionFile}") + } +} diff --git a/luceedebug/src/main/java/luceedebug/Agent.java b/luceedebug/src/main/java/luceedebug/Agent.java index d8390ab..5eb21b1 100644 --- a/luceedebug/src/main/java/luceedebug/Agent.java +++ b/luceedebug/src/main/java/luceedebug/Agent.java @@ -186,7 +186,17 @@ private static Map linearizedCoreInjectClasses() { result.put("luceedebug.coreinject.frame.Frame$FrameContext", 1); result.put("luceedebug.coreinject.frame.Frame$FrameContext$SupplierOrNull", 1); result.put("luceedebug.coreinject.frame.DummyFrame", 1); - + result.put("luceedebug.coreinject.frame.NativeDebugFrame", 1); + + // Native debugger listener for Lucee7+ native breakpoints + result.put("luceedebug.coreinject.NativeDebuggerListener", 0); + result.put("luceedebug.coreinject.NativeDebuggerListener$1", 0); + result.put("luceedebug.coreinject.NativeDebuggerListener$StepState", 0); + result.put("luceedebug.coreinject.StepMode", 0); + + // Native-only LuceeVm implementation (no JDWP) + result.put("luceedebug.coreinject.NativeLuceeVm", 0); + return result; } diff --git a/luceedebug/src/main/java/luceedebug/Config.java b/luceedebug/src/main/java/luceedebug/Config.java index 15f68ec..e112fa8 100644 --- a/luceedebug/src/main/java/luceedebug/Config.java +++ b/luceedebug/src/main/java/luceedebug/Config.java @@ -8,7 +8,7 @@ public class Config { // but for now it's configurable private boolean stepIntoUdfDefaultValueInitFrames_ = false; - Config(boolean fsIsCaseSensitive) { + public Config(boolean fsIsCaseSensitive) { this.fsIsCaseSensitive_ = fsIsCaseSensitive; } diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index 2fcf32d..bb5d201 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -24,8 +24,6 @@ import org.eclipse.lsp4j.jsonrpc.services.JsonRequest; import org.eclipse.lsp4j.jsonrpc.util.ToStringBuilder; -import com.sun.jdi.ObjectCollectedException; - import luceedebug.strong.CanonicalServerAbsPath; import luceedebug.strong.RawIdePath; @@ -72,16 +70,16 @@ private DapServer(ILuceeVm luceeVm, Config config) { this.luceeVm_ = luceeVm; this.config_ = config; - this.luceeVm_.registerStepEventCallback(jdwpThreadID -> { - final var i32_threadID = (int)(long)jdwpThreadID.get(); + this.luceeVm_.registerStepEventCallback(threadID -> { + final var i32_threadID = (int)(long)threadID; var event = new StoppedEventArguments(); event.setReason("step"); event.setThreadId(i32_threadID); clientProxy_.stopped(event); }); - this.luceeVm_.registerBreakpointEventCallback((jdwpThreadID, bpID) -> { - final int i32_threadID = (int)(long)jdwpThreadID.get(); + this.luceeVm_.registerBreakpointEventCallback((threadID, bpID) -> { + final int i32_threadID = (int)(long)threadID; var event = new StoppedEventArguments(); event.setReason("breakpoint"); event.setThreadId(i32_threadID); @@ -113,6 +111,23 @@ private DapServer(ILuceeVm luceeVm, Config config) { clientProxy_.breakpoint(bpEvent); } }); + + // Register native breakpoint callback (Lucee7+ native suspend) + // Uses Java thread ID directly since native suspend doesn't go through JDWP + this.luceeVm_.registerNativeBreakpointEventCallback((javaThreadId, label) -> { + // Use Java thread ID directly as DAP thread ID for native breakpoints + // This is different from JDWP breakpoints which use JDWP thread IDs + final int i32_threadID = (int)(long)javaThreadId; + var event = new StoppedEventArguments(); + event.setReason("breakpoint"); + event.setThreadId(i32_threadID); + // Set label as description if provided (from programmatic breakpoint("label") calls) + if (label != null && !label.isEmpty()) { + event.setDescription(label); + } + clientProxy_.stopped(event); + System.out.println("[luceedebug] Sent DAP stopped event for native breakpoint, thread=" + javaThreadId + (label != null ? " label=" + label : "")); + }); } static class DapEntry { @@ -262,22 +277,11 @@ public CompletableFuture attach(Map args) { public CompletableFuture threads() { var lspThreads = new ArrayList(); - for (var threadRef : luceeVm_.getThreadListing()) { - try { - var lspThread = new org.eclipse.lsp4j.debug.Thread(); - lspThread.setId((int)threadRef.uniqueID()); // <<<<----------------@fixme, ObjectCollectedExceptions here - lspThread.setName(threadRef.name()); // <<<<----------------@fixme, ObjectCollectedExceptions here - lspThreads.add(lspThread); - } - catch (ObjectCollectedException e) { - // Discard this exception. - // We really shouldn't be dealing in terms of jdi thread refs here. - // The luceevm should return a list of names and IDs rather than actual threadrefs. - } - catch (Throwable e) { - e.printStackTrace(); - System.exit(1); - } + for (var threadInfo : luceeVm_.getThreadListing()) { + var lspThread = new org.eclipse.lsp4j.debug.Thread(); + lspThread.setId((int)threadInfo.id); + lspThread.setName(threadInfo.name); + lspThreads.add(lspThread); } // a lot of thread names like "Thread-Foo-1" and "Thread-Foo-12" which we'd like to order in a nice way diff --git a/luceedebug/src/main/java/luceedebug/ILuceeVm.java b/luceedebug/src/main/java/luceedebug/ILuceeVm.java index b3c6759..ed6c295 100644 --- a/luceedebug/src/main/java/luceedebug/ILuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/ILuceeVm.java @@ -3,16 +3,30 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; -import com.sun.jdi.*; - import luceedebug.strong.DapBreakpointID; -import luceedebug.strong.JdwpThreadID; import luceedebug.strong.CanonicalServerAbsPath; import luceedebug.strong.RawIdePath; public interface ILuceeVm { - public void registerStepEventCallback(Consumer cb); - public void registerBreakpointEventCallback(BiConsumer cb); + /** + * Register callback for step events. + * Called with thread ID when a step completes. + */ + public void registerStepEventCallback(Consumer cb); + + /** + * Register callback for JDWP breakpoint events. + * Called with thread ID and breakpoint ID when a JDWP breakpoint is hit. + * For NativeLuceeVm, this is unused (use registerNativeBreakpointEventCallback instead). + */ + public void registerBreakpointEventCallback(BiConsumer cb); + + /** + * Register callback for native breakpoint events (Lucee7+). + * Called with Java thread ID and optional label when a thread hits a native breakpoint. + * Label is non-null for programmatic breakpoint("label") calls, null otherwise. + */ + public void registerNativeBreakpointEventCallback(BiConsumer cb); public static class BreakpointsChangedEvent { IBreakpoint[] newBreakpoints = new IBreakpoint[0]; @@ -27,8 +41,8 @@ public static BreakpointsChangedEvent justChanges(IBreakpoint[] changes) { } public void registerBreakpointsChangedCallback(Consumer cb); - public ThreadReference[] getThreadListing(); - public IDebugFrame[] getStackTrace(long jdwpThreadID); + public ThreadInfo[] getThreadListing(); + public IDebugFrame[] getStackTrace(long threadID); public IDebugEntity[] getScopes(long frameID); /** @@ -44,13 +58,13 @@ public static BreakpointsChangedEvent justChanges(IBreakpoint[] changes) { public IBreakpoint[] bindBreakpoints(RawIdePath idePath, CanonicalServerAbsPath serverAbsPath, int[] lines, String[] exprs); - public void continue_(long jdwpThreadID); + public void continue_(long threadID); public void continueAll(); - public void stepIn(long jdwpThreadID); - public void stepOver(long jdwpThreadID); - public void stepOut(long jdwpThreadID); + public void stepIn(long threadID); + public void stepOver(long threadID); + public void stepOut(long threadID); public void clearAllBreakpoints(); diff --git a/luceedebug/src/main/java/luceedebug/LuceeTransformer.java b/luceedebug/src/main/java/luceedebug/LuceeTransformer.java index 875d3a8..f010704 100644 --- a/luceedebug/src/main/java/luceedebug/LuceeTransformer.java +++ b/luceedebug/src/main/java/luceedebug/LuceeTransformer.java @@ -87,6 +87,9 @@ else if (className.equals("lucee/runtime/PageContextImpl")) { System.out.println("[luceedebug] Loaded " + GlobalIDebugManagerHolder.debugManager + " with ClassLoader '" + GlobalIDebugManagerHolder.debugManager.getClass().getClassLoader() + "'"); GlobalIDebugManagerHolder.debugManager.spawnWorker(config, jdwpHost, jdwpPort, debugHost, debugPort); + + // Register native debugger listener for Lucee7+ native breakpoints + registerNativeDebuggerListener(loader); } catch (Throwable e) { e.printStackTrace(); @@ -236,4 +239,67 @@ protected ClassLoader getClassLoader() { return null; } } + + /** + * Register our NativeDebuggerListener with Lucee's DebuggerRegistry (if available). + * This enables native breakpoints in Lucee7+ without JDWP instrumentation. + * + * The listener is registered via reflection since DebuggerListener/DebuggerRegistry + * are in Lucee core, not the loader. + */ + private void registerNativeDebuggerListener(ClassLoader luceeLoader) { + try { + // Check if DebuggerRegistry exists (Lucee7+ feature) + Class registryClass; + try { + registryClass = luceeLoader.loadClass("lucee.runtime.debug.DebuggerRegistry"); + } catch (ClassNotFoundException e) { + System.out.println("[luceedebug] DebuggerRegistry not found - native breakpoints not available (pre-Lucee7)"); + return; + } + + // Load the DebuggerListener interface + Class listenerInterface = luceeLoader.loadClass("lucee.runtime.debug.DebuggerListener"); + + // Load our NativeDebuggerListener class (already injected into core loader) + Class nativeListenerClass = GlobalIDebugManagerHolder.luceeCoreLoader.loadClass("luceedebug.coreinject.NativeDebuggerListener"); + Class pageContextClass = luceeLoader.loadClass("lucee.runtime.PageContext"); + + // Cache method lookups - shouldSuspend is on the hot path (called every line) + final Method onSuspendMethod = nativeListenerClass.getMethod("onSuspend", + pageContextClass, String.class, int.class, String.class); + final Method onResumeMethod = nativeListenerClass.getMethod("onResume", pageContextClass); + final Method shouldSuspendMethod = nativeListenerClass.getMethod("shouldSuspend", + pageContextClass, String.class, int.class); + + // Create a dynamic proxy that implements DebuggerListener and delegates to our static methods + Object listenerProxy = java.lang.reflect.Proxy.newProxyInstance( + luceeLoader, + new Class[] { listenerInterface }, + (proxy, method, args) -> { + String methodName = method.getName(); + switch (methodName) { + case "onSuspend": + return onSuspendMethod.invoke(null, args); + case "onResume": + return onResumeMethod.invoke(null, args); + case "shouldSuspend": + return shouldSuspendMethod.invoke(null, args); + default: + throw new UnsupportedOperationException("Unknown method: " + methodName); + } + } + ); + + // Register the listener + Method setListener = registryClass.getMethod("setListener", listenerInterface); + setListener.invoke(null, listenerProxy); + + System.out.println("[luceedebug] Registered native debugger listener for Lucee7+ breakpoints"); + } catch (Throwable e) { + System.out.println("[luceedebug] Failed to register native debugger listener: " + e.getMessage()); + e.printStackTrace(); + // Don't exit - native breakpoints are optional, JDWP breakpoints still work + } + } } diff --git a/luceedebug/src/main/java/luceedebug/ThreadInfo.java b/luceedebug/src/main/java/luceedebug/ThreadInfo.java new file mode 100644 index 0000000..10bb565 --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/ThreadInfo.java @@ -0,0 +1,15 @@ +package luceedebug; + +/** + * Simple thread information for DAP. + * Replaces JDWP ThreadReference dependency in ILuceeVm. + */ +public class ThreadInfo { + public final long id; + public final String name; + + public ThreadInfo(long id, String name) { + this.id = id; + this.name = name; + } +} diff --git a/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java b/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java index d8c410e..a684230 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java @@ -42,6 +42,16 @@ public CfValueDebuggerBridge(Frame frame, Object obj) { this.id = frame.valTracker.idempotentRegisterObject(obj).id; } + /** + * Constructor for use with native Lucee7 debugger frames where we don't have a Frame object. + * The frame field will be null - this is OK since it's not used after construction. + */ + public CfValueDebuggerBridge(ValTracker valTracker, Object obj) { + this.frame = null; // Not available for native frames + this.obj = Objects.requireNonNull(obj); + this.id = valTracker.idempotentRegisterObject(obj).id; + } + public long getID() { return id; } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java b/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java index ba008bd..df407fd 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java @@ -35,6 +35,7 @@ import luceedebug.coreinject.frame.DebugFrame; import luceedebug.coreinject.frame.Frame; import luceedebug.coreinject.frame.Frame.FrameContext; +import luceedebug.coreinject.frame.NativeDebugFrame; public class DebugManager implements IDebugManager { @@ -500,10 +501,30 @@ synchronized public IDebugEntity[] getVariables(long id, IDebugEntity.DebugEntit synchronized public IDebugFrame[] getCfStack(Thread thread) { ArrayList stack = cfStackByThread.get(thread); - if (stack == null) { - System.out.println("getCfStack called, frames was null, frames is " + cfStackByThread + ", passed thread was " + thread); - System.out.println(" thread=" + thread + " this=" + this); - return new Frame[0]; + + // If no instrumented frames, try native Lucee7 frames + if (stack == null || stack.isEmpty()) { + // Try our tracked PageContext first + WeakReference pcRef = pageContextByThread.get(thread); + PageContext pc = pcRef != null ? pcRef.get() : null; + + // Fall back to ThreadLocalPageContext if we're on the same thread + if (pc == null && thread == Thread.currentThread()) { + pc = lucee.runtime.engine.ThreadLocalPageContext.get(); + } + + if (pc != null) { + IDebugFrame[] nativeFrames = NativeDebugFrame.getNativeFrames(pc, valTracker); + if (nativeFrames != null && nativeFrames.length > 0) { + return nativeFrames; + } + } + + if (stack == null) { + System.out.println("getCfStack called, frames was null, frames is " + cfStackByThread + ", passed thread was " + thread); + System.out.println(" thread=" + thread + " this=" + this); + return new Frame[0]; + } } ArrayList result = new ArrayList<>(); @@ -564,6 +585,7 @@ public void registerStepRequest(Thread thread, int type) { // fallthrough case CfStepRequest.STEP_OUT: { stepRequestByThread.put(thread, new CfStepRequest(frame.getDepth(), type)); + hasAnyStepRequests = true; return; } default: { @@ -577,12 +599,20 @@ public void registerStepRequest(Thread thread, int type) { // This holds strongrefs to Thread objects, but requests should be cleared out after their completion // It doesn't make sense to have a step request for thread that would otherwise be reclaimable but for our reference to it here private ConcurrentHashMap stepRequestByThread = new ConcurrentHashMap<>(); + // Fast-path flag: volatile read is cheaper than ConcurrentHashMap.isEmpty() or .get() + private volatile boolean hasAnyStepRequests = false; public void clearStepRequest(Thread thread) { stepRequestByThread.remove(thread); + hasAnyStepRequests = !stepRequestByThread.isEmpty(); } public void luceedebug_stepNotificationEntry_step(int lineNumber) { + // Fast path: single volatile read when not stepping (99.9% of the time) + if (!hasAnyStepRequests) { + return; + } + final int minDistanceToLuceedebugStepNotificationEntryFrame = 0; Thread currentThread = Thread.currentThread(); DebugFrame frame = maybeUpdateTopmostFrame(currentThread, lineNumber); // should be "definite update topmost frame", we 100% expect there to be a frame @@ -606,10 +636,15 @@ else if (frame instanceof Frame) { * So we want the debugger to return to the callsite in the normal case, but jump to any catch/finally blocks in the exceptional case. */ public void luceedebug_stepNotificationEntry_stepAfterCompletedUdfCall() { + // Fast path: single volatile read when not stepping (99.9% of the time) + if (!hasAnyStepRequests) { + return; + } + final int minDistanceToLuceedebugStepNotificationEntryFrame = 0; Thread currentThread = Thread.currentThread(); - DebugFrame frame = getTopmostFrame(Thread.currentThread()); + DebugFrame frame = getTopmostFrame(currentThread); if (frame == null) { // just popped last frame? diff --git a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java index e4e4ba2..2360f97 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java @@ -461,6 +461,20 @@ public LuceeVm(Config config, VirtualMachine vm) { // We'll have set done=true prior to resuming this thread. while (!done.get()); // about ~8ms to queueWork + wait for work to complete }); + + // Register native breakpoint suspend callback (Lucee7+) + NativeDebuggerListener.setOnNativeSuspendCallback((javaThreadId, label) -> { + if (nativeBreakpointEventCallback != null) { + nativeBreakpointEventCallback.accept(javaThreadId, label); + } + }); + + // Register native step callback (Lucee7+) + NativeDebuggerListener.setOnNativeStepCallback(javaThreadId -> { + if (stepEventCallback != null) { + stepEventCallback.accept(javaThreadId); + } + }); } /** @@ -471,15 +485,21 @@ public LuceeVm(Config config, VirtualMachine vm) { */ private static enum SteppingState { stepping, finalizingViaAwaitedBreakpoint } private ConcurrentMap steppingStatesByThread = new ConcurrentHashMap<>(); - private Consumer stepEventCallback = null; - private BiConsumer breakpointEventCallback = null; + private Consumer stepEventCallback = null; + + /** + * Callback for native breakpoint events (Lucee7+ native suspend). + * Called with Java thread ID and optional label when a thread hits a native breakpoint. + */ + private BiConsumer nativeBreakpointEventCallback = null; + private BiConsumer breakpointEventCallback = null; private Consumer breakpointsChangedCallback = null; - public void registerStepEventCallback(Consumer cb) { + public void registerStepEventCallback(Consumer cb) { stepEventCallback = cb; } - public void registerBreakpointEventCallback(BiConsumer cb) { + public void registerBreakpointEventCallback(BiConsumer cb) { breakpointEventCallback = cb; } @@ -487,6 +507,14 @@ public void registerBreakpointsChangedCallback(Consumer this.breakpointsChangedCallback = cb; } + /** + * Register callback for native breakpoint events (Lucee7+). + * Called with Java thread ID and optional label when a thread hits a native breakpoint. + */ + public void registerNativeBreakpointEventCallback(BiConsumer cb) { + nativeBreakpointEventCallback = cb; + } + private void initEventPump() { new java.lang.Thread(() -> { try { @@ -666,7 +694,7 @@ private void handleBreakpointEvent(BreakpointEvent event) { // We would delete the breakpoint request here, // but it should have been registered with an eventcount filter of 1, // meaning that it has auto-expired - stepEventCallback.accept(JdwpThreadID.of(event.thread())); + stepEventCallback.accept(threadID.get()); } } else { @@ -692,18 +720,23 @@ private void handleBreakpointEvent(BreakpointEvent event) { if (breakpointEventCallback != null) { final var bpID = (DapBreakpointID) request.getProperty(LUCEEDEBUG_BREAKPOINT_ID); - breakpointEventCallback.accept(threadID, bpID); + breakpointEventCallback.accept(threadID.get(), bpID); } } } - public ThreadReference[] getThreadListing() { - var result = new ArrayList(); + public ThreadInfo[] getThreadListing() { + var result = new ArrayList(); for (var threadRef : threadMap_.threadRefByThread.values()) { - result.add(threadRef); + try { + result.add(new ThreadInfo(threadRef.uniqueID(), threadRef.name())); + } + catch (ObjectCollectedException e) { + // Thread was garbage collected, skip it + } } - return result.toArray(size -> new ThreadReference[size]); + return result.toArray(size -> new ThreadInfo[size]); } public IDebugFrame[] getStackTrace(long jdwpThreadId) { @@ -786,6 +819,25 @@ private BpLineAndId[] freshBpLineAndIdRecordsFromLines(RawIdePath idePath, Canon } public IBreakpoint[] bindBreakpoints(RawIdePath idePath, CanonicalServerAbsPath serverPath, int[] lines, String[] exprs) { + // Register native breakpoints (Lucee7+) + // Clear existing native breakpoints for this file first + NativeDebuggerListener.clearBreakpointsForFile(serverPath.get()); + for (int line : lines) { + NativeDebuggerListener.addBreakpoint(serverPath.get(), line); + } + + // In native-only mode, skip JDWP breakpoint registration entirely + if (NativeDebuggerListener.isNativeOnlyMode()) { + // Return unbound breakpoints - native breakpoints don't have JDWP binding info + var lineInfo = freshBpLineAndIdRecordsFromLines(idePath, serverPath, lines, exprs); + IBreakpoint[] result = new Breakpoint[lineInfo.length]; + for (int i = 0; i < lineInfo.length; i++) { + // Mark as bound since native breakpoints are always "bound" (no class loading dependency) + result[i] = Breakpoint.Bound(lineInfo[i].line, lineInfo[i].id); + } + return result; + } + return __internal__bindBreakpoints(serverPath, freshBpLineAndIdRecordsFromLines(idePath, serverPath, lines, exprs)); } @@ -915,6 +967,14 @@ private void clearExistingBreakpoints(CanonicalServerAbsPath absPath) { } public void clearAllBreakpoints() { + // Clear native breakpoints (Lucee7+) + NativeDebuggerListener.clearAllBreakpoints(); + + // In native-only mode, skip JDWP operations + if (NativeDebuggerListener.isNativeOnlyMode()) { + return; + } + replayableBreakpointRequestsByAbsPath_.clear(); vm_.eventRequestManager().deleteAllBreakpoints(); } @@ -956,6 +1016,10 @@ private void continue_(ThreadReference threadRef) { } public void continueAll() { + // Resume all natively suspended threads (Lucee7+ native breakpoints) + NativeDebuggerListener.resumeAllNativeThreads(); + + // Resume all JDWP suspended threads // avoid concurrent modification exceptions, calling continue_ mutates `suspendedThreads` Arrays // TODO: Set.toArray(sz -> new T[sz]) is not typesafe, changing the type of Set @@ -977,8 +1041,16 @@ public void stepIn(long jdwpThreadID) { stepIn(new JdwpThreadID(jdwpThreadID)); } - public void continue_(long jdwpThreadID) { - continue_(new JdwpThreadID(jdwpThreadID)); + public void continue_(long threadID) { + // First, try to resume as a natively suspended thread (Lucee7+ native breakpoints) + // Native breakpoints use Java thread IDs directly + if (NativeDebuggerListener.resumeNativeThread(threadID)) { + System.out.println("[luceedebug] Resumed natively suspended thread: " + threadID); + return; + } + + // Fall back to JDWP resume + continue_(new JdwpThreadID(threadID)); } public void stepIn(JdwpThreadID jdwpThreadID) { diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java new file mode 100644 index 0000000..890f742 --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java @@ -0,0 +1,408 @@ +package luceedebug.coreinject; + +import java.lang.ref.WeakReference; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import lucee.runtime.PageContext; +import luceedebug.Config; + +/** + * Step mode for stepping operations. + */ +enum StepMode { + NONE, + STEP_INTO, + STEP_OVER, + STEP_OUT +} + +/** + * Implementation of Lucee's DebuggerListener interface for native breakpoint support. + * This allows luceedebug to receive suspend/resume callbacks and manage breakpoints + * without JDWP instrumentation. + * + * Note: This class implements the interface via reflection since it's in Lucee core, + * not in the loader. We create a dynamic proxy that forwards calls to this class. + * + * TODO: Consider using DAP OutputEvent to show debug messages in VS Code Debug Console + * instead of System.out.println. This would give users visibility into native breakpoint + * activity within the IDE. + */ +public class NativeDebuggerListener { + + /** + * Map of "file:line" -> true for active breakpoints. + * Uses ConcurrentHashMap for thread-safe access from multiple request threads. + */ + private static final ConcurrentHashMap breakpoints = new ConcurrentHashMap<>(); + + /** + * Map of "file:line" -> condition expression for conditional breakpoints. + * If a breakpoint has no condition, it won't have an entry here. + */ + private static final ConcurrentHashMap breakpointConditions = new ConcurrentHashMap<>(); + + /** + * Map of Java thread ID -> WeakReference for natively suspended threads. + * Used to call debuggerResume() when DAP continue is received. + * Note: We use PageContext (loader interface) not PageContextImpl to avoid class loading cycles. + */ + private static final ConcurrentHashMap> nativelySuspendedThreads = new ConcurrentHashMap<>(); + + /** + * Callback to notify LuceeVm when a thread suspends via native breakpoint. + * Called with Java thread ID and optional label. Used for "breakpoint" stop reason in DAP. + * Label is non-null for programmatic breakpoint("label") calls, null otherwise. + */ + private static volatile BiConsumer onNativeSuspendCallback = null; + + /** + * Callback to notify LuceeVm when a thread stops after a step. + * Called with Java thread ID. Used for "step" stop reason in DAP. + */ + private static volatile Consumer onNativeStepCallback = null; + + /** + * Flag to indicate native-only mode (no JDWP breakpoints). + * When true, only native breakpoints are used. + */ + private static volatile boolean nativeOnlyMode = false; + + /** + * Per-thread stepping state. + */ + private static final ConcurrentHashMap steppingThreads = new ConcurrentHashMap<>(); + + /** + * Stepping state for a single thread. + */ + private static class StepState { + final StepMode mode; + final int startDepth; + + StepState(StepMode mode, int startDepth) { + this.mode = mode; + this.startDepth = startDepth; + } + } + + /** + * Enable native-only mode (skip JDWP breakpoint registration). + */ + public static void setNativeOnlyMode(boolean enabled) { + nativeOnlyMode = enabled; + System.out.println("[luceedebug] Native-only mode: " + enabled); + } + + /** + * Check if native-only mode is enabled. + */ + public static boolean isNativeOnlyMode() { + return nativeOnlyMode; + } + + /** + * Set the callback for native suspend events (breakpoints). + * LuceeVm should register this to receive notifications and send DAP stopped events. + */ + public static void setOnNativeSuspendCallback(BiConsumer callback) { + onNativeSuspendCallback = callback; + } + + /** + * Set the callback for native step events. + * LuceeVm should register this to receive notifications and send DAP step events. + */ + public static void setOnNativeStepCallback(Consumer callback) { + onNativeStepCallback = callback; + } + + /** + * Add a breakpoint at the given file and line. + */ + public static void addBreakpoint(String file, int line) { + addBreakpoint(file, line, null); + } + + /** + * Add a breakpoint at the given file and line with optional condition. + * @param condition CFML expression to evaluate, or null for unconditional breakpoint + */ + public static void addBreakpoint(String file, int line, String condition) { + String key = makeKey(file, line); + breakpoints.put(key, Boolean.TRUE); + if (condition != null && !condition.isEmpty()) { + breakpointConditions.put(key, condition); + System.out.println("[luceedebug] Added native breakpoint: " + key + " condition=" + condition); + } else { + breakpointConditions.remove(key); // ensure no stale condition + System.out.println("[luceedebug] Added native breakpoint: " + key); + } + } + + /** + * Remove a breakpoint at the given file and line. + */ + public static void removeBreakpoint(String file, int line) { + String key = makeKey(file, line); + breakpoints.remove(key); + breakpointConditions.remove(key); + System.out.println("[luceedebug] Removed native breakpoint: " + key); + } + + /** + * Clear all breakpoints for a given file. + */ + public static void clearBreakpointsForFile(String file) { + String prefix = Config.canonicalizeFileName(file) + ":"; + breakpoints.keySet().removeIf(key -> key.startsWith(prefix)); + breakpointConditions.keySet().removeIf(key -> key.startsWith(prefix)); + System.out.println("[luceedebug] Cleared native breakpoints for: " + file); + } + + /** + * Clear all breakpoints. + */ + public static void clearAllBreakpoints() { + breakpoints.clear(); + breakpointConditions.clear(); + System.out.println("[luceedebug] Cleared all native breakpoints"); + } + + /** + * Get breakpoint count (for debugging). + */ + public static int getBreakpointCount() { + return breakpoints.size(); + } + + /** + * Check if a thread is natively suspended. + */ + public static boolean isNativelySuspended(long javaThreadId) { + return nativelySuspendedThreads.containsKey(javaThreadId); + } + + /** + * Get any PageContext from the suspended threads map. + * Used to bootstrap access to CFMLFactory for thread listing. + * @return a PageContext if any thread is suspended, null otherwise + */ + public static PageContext getAnyPageContext() { + for (WeakReference ref : nativelySuspendedThreads.values()) { + PageContext pc = ref.get(); + if (pc != null) { + return pc; + } + } + return null; + } + + /** + * Resume a natively suspended thread by calling debuggerResume() on its PageContext. + * Uses reflection since debuggerResume() is a Lucee7+ method not in the loader interface. + * @return true if the thread was found and resumed, false otherwise + */ + public static boolean resumeNativeThread(long javaThreadId) { + WeakReference pcRef = nativelySuspendedThreads.remove(javaThreadId); + if (pcRef == null) { + return false; + } + PageContext pc = pcRef.get(); + if (pc == null) { + return false; + } + System.out.println("[luceedebug] Resuming native thread: " + javaThreadId); + try { + // Call debuggerResume() via reflection (Lucee7+ method) + java.lang.reflect.Method resumeMethod = pc.getClass().getMethod("debuggerResume"); + resumeMethod.invoke(pc); + return true; + } catch (NoSuchMethodException e) { + System.out.println("[luceedebug] debuggerResume() not available (pre-Lucee7?)"); + return false; + } catch (Exception e) { + System.out.println("[luceedebug] Error calling debuggerResume(): " + e.getMessage()); + e.printStackTrace(); + return false; + } + } + + /** + * Resume all natively suspended threads. + */ + public static void resumeAllNativeThreads() { + for (Long threadId : nativelySuspendedThreads.keySet()) { + resumeNativeThread(threadId); + } + } + + // ========== Stepping methods ========== + + /** + * Start stepping for a thread. + * @param threadId The Java thread ID + * @param mode The step mode (STEP_INTO, STEP_OVER, STEP_OUT) + * @param currentDepth The current stack depth when stepping started + */ + public static void startStepping(long threadId, StepMode mode, int currentDepth) { + steppingThreads.put(threadId, new StepState(mode, currentDepth)); + System.out.println("[luceedebug] Start stepping: thread=" + threadId + " mode=" + mode + " depth=" + currentDepth); + } + + /** + * Stop stepping for a thread. + */ + public static void stopStepping(long threadId) { + steppingThreads.remove(threadId); + } + + /** + * Get the current stack depth for a PageContext. + * Uses reflection to get debugger frames. + */ + public static int getStackDepth(PageContext pc) { + try { + java.lang.reflect.Method getFrames = pc.getClass().getMethod("getDebuggerFrames"); + Object[] frames = (Object[]) getFrames.invoke(pc); + return frames != null ? frames.length : 0; + } catch (Exception e) { + // Log error - silent failure could cause incorrect step behavior + System.out.println("[luceedebug] Error getting stack depth: " + e.getMessage()); + return 0; + } + } + + // ========== DebuggerListener interface methods ========== + + /** + * Called by Lucee when a thread is about to suspend. + * This is invoked on the suspending thread's stack, before it blocks. + */ + public static void onSuspend(PageContext pc, String file, int line, String label) { + long threadId = Thread.currentThread().getId(); + System.out.println("[luceedebug] Native suspend: thread=" + threadId + " file=" + file + " line=" + line + " label=" + label); + + // Check if we were stepping BEFORE clearing state + StepState stepState = steppingThreads.remove(threadId); + boolean wasStepping = (stepState != null); + + // Check if we hit a breakpoint (breakpoint wins over step) + boolean hitBreakpoint = breakpoints.containsKey(makeKey(file, line)); + + // Track the suspended thread so we can resume it later + // We store PageContext (not PageContextImpl) to avoid class loading cycles + nativelySuspendedThreads.put(threadId, new WeakReference<>(pc)); + + // Fire appropriate callback - breakpoint takes precedence over step + if (hitBreakpoint) { + // Stopped at breakpoint (no label for line breakpoints) + BiConsumer callback = onNativeSuspendCallback; + if (callback != null) { + callback.accept(threadId, null); + } + } else if (wasStepping) { + // Stopped due to stepping + Consumer callback = onNativeStepCallback; + if (callback != null) { + callback.accept(threadId); + } + } else { + // Programmatic breakpoint() call or other suspend - pass the label + BiConsumer callback = onNativeSuspendCallback; + if (callback != null) { + callback.accept(threadId, label); + } + } + } + + /** + * Called by Lucee when a thread resumes after suspension. + */ + public static void onResume(PageContext pc) { + long threadId = Thread.currentThread().getId(); + System.out.println("[luceedebug] Native resume: thread=" + threadId); + + // Remove from suspended threads map + nativelySuspendedThreads.remove(threadId); + } + + /** + * Called by Lucee's DebuggerExecutionLog.start() on every line. + * Checks breakpoints AND stepping state. + * Must be fast - this is on the hot path. + */ + public static boolean shouldSuspend(PageContext pc, String file, int line) { + // Check breakpoints first (most common case) + String key = makeKey(file, line); + if (breakpoints.containsKey(key)) { + // Check if there's a condition + String condition = breakpointConditions.get(key); + if (condition != null) { + // Evaluate condition - only suspend if true + return evaluateCondition(pc, condition); + } + return true; + } + + // Check stepping state + long threadId = Thread.currentThread().getId(); + StepState stepState = steppingThreads.get(threadId); + if (stepState == null) { + return false; + } + + int currentDepth = getStackDepth(pc); + + switch (stepState.mode) { + case STEP_INTO: + // Always stop on next line + return true; + + case STEP_OVER: + // Stop when at same or shallower depth + return currentDepth <= stepState.startDepth; + + case STEP_OUT: + // Stop when shallower than start depth + return currentDepth < stepState.startDepth; + + default: + return false; + } + } + + /** + * Evaluate a CFML condition expression and return its boolean result. + * Returns false if evaluation fails (exception, timeout, etc.). + */ + private static boolean evaluateCondition(PageContext pc, String condition) { + try { + // Use Evaluate.call() directly on PageContext - same approach as DebugManager + Object result = lucee.runtime.functions.dynamicEvaluation.Evaluate.call( + pc, + new String[]{ condition } + ); + return lucee.runtime.op.Caster.toBoolean(result); + } catch (Exception e) { + // Condition evaluation failed - don't suspend + // Log but don't spam - conditions may intentionally reference undefined vars + System.out.println("[luceedebug] Condition evaluation failed: " + e.getMessage()); + return false; + } + } + + /** + * Check if a breakpoint exists at the given file and line. + */ + public static boolean hasBreakpoint(String file, int line) { + String key = makeKey(file, line); + return breakpoints.containsKey(key); + } + + private static String makeKey(String file, int line) { + return Config.canonicalizeFileName(file) + ":" + line; + } +} diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java new file mode 100644 index 0000000..c27345a --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java @@ -0,0 +1,265 @@ +package luceedebug.coreinject; + +import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import luceedebug.*; +import luceedebug.strong.DapBreakpointID; +import luceedebug.strong.CanonicalServerAbsPath; +import luceedebug.strong.RawIdePath; + +/** + * Native implementation of ILuceeVm that uses only Lucee7+ native debugging APIs. + * No JDWP connection, no bytecode instrumentation, no agent required. + * + * This is for extension-only deployment where luceedebug runs as a Lucee extension + * rather than a Java agent. + */ +public class NativeLuceeVm implements ILuceeVm { + + private final Config config_; + + private Consumer stepEventCallback = null; + private BiConsumer breakpointEventCallback = null; + private BiConsumer nativeBreakpointEventCallback = null; + private Consumer breakpointsChangedCallback = null; + + private AtomicInteger breakpointID = new AtomicInteger(); + + public NativeLuceeVm(Config config) { + this.config_ = config; + + // Enable native-only mode + NativeDebuggerListener.setNativeOnlyMode(true); + + // Register native breakpoint suspend callback + NativeDebuggerListener.setOnNativeSuspendCallback((javaThreadId, label) -> { + if (nativeBreakpointEventCallback != null) { + nativeBreakpointEventCallback.accept(javaThreadId, label); + } + }); + + // Register native step callback + NativeDebuggerListener.setOnNativeStepCallback(javaThreadId -> { + if (stepEventCallback != null) { + stepEventCallback.accept(javaThreadId); + } + }); + } + + private DapBreakpointID nextDapBreakpointID() { + return new DapBreakpointID(breakpointID.incrementAndGet()); + } + + // ========== Callback registration ========== + + @Override + public void registerStepEventCallback(Consumer cb) { + stepEventCallback = cb; + } + + @Override + public void registerBreakpointEventCallback(BiConsumer cb) { + // Not used in native-only mode - native breakpoints don't have JDWP breakpoint IDs + breakpointEventCallback = cb; + } + + @Override + public void registerNativeBreakpointEventCallback(BiConsumer cb) { + nativeBreakpointEventCallback = cb; + } + + @Override + public void registerBreakpointsChangedCallback(Consumer cb) { + breakpointsChangedCallback = cb; + } + + // ========== Thread operations ========== + + @Override + public ThreadInfo[] getThreadListing() { + var result = new ArrayList(); + + // Get active PageContexts from Lucee's CFMLFactory + // We need a PageContext to get the factory - try ThreadLocalPageContext or suspended threads + lucee.runtime.PageContext anyPc = lucee.runtime.engine.ThreadLocalPageContext.get(); + + // If not on a request thread, try to get from suspended threads + if (anyPc == null) { + anyPc = NativeDebuggerListener.getAnyPageContext(); + } + + if (anyPc != null) { + try { + // Get the CFMLFactory from the PageContext + Object factory = anyPc.getCFMLFactory(); + + // Call getActivePageContexts() via reflection (it's in CFMLFactoryImpl, not loader) + java.lang.reflect.Method getActiveMethod = factory.getClass().getMethod("getActivePageContexts"); + @SuppressWarnings("unchecked") + java.util.Map activeContexts = (java.util.Map) getActiveMethod.invoke(factory); + + // Each PageContext has a getThread() method + for (Object pc : activeContexts.values()) { + try { + java.lang.reflect.Method getThreadMethod = pc.getClass().getMethod("getThread"); + Thread thread = (Thread) getThreadMethod.invoke(pc); + if (thread != null) { + result.add(new ThreadInfo(thread.getId(), thread.getName())); + } + } catch (Exception e) { + // Skip this context if we can't get its thread + } + } + } catch (Exception e) { + System.out.println("[luceedebug] Error getting active page contexts: " + e.getMessage()); + } + } + + return result.toArray(new ThreadInfo[0]); + } + + @Override + public IDebugFrame[] getStackTrace(long threadID) { + // Get the thread and use DebugManager to get CF stack + Thread thread = findThreadById(threadID); + if (thread == null) { + return new IDebugFrame[0]; + } + return GlobalIDebugManagerHolder.debugManager.getCfStack(thread); + } + + private Thread findThreadById(long threadId) { + for (Thread t : Thread.getAllStackTraces().keySet()) { + if (t.getId() == threadId) { + return t; + } + } + return null; + } + + // ========== Variable operations ========== + + @Override + public IDebugEntity[] getScopes(long frameID) { + return GlobalIDebugManagerHolder.debugManager.getScopesForFrame(frameID); + } + + @Override + public IDebugEntity[] getVariables(long ID) { + return GlobalIDebugManagerHolder.debugManager.getVariables(ID, null); + } + + @Override + public IDebugEntity[] getNamedVariables(long ID) { + return GlobalIDebugManagerHolder.debugManager.getVariables(ID, IDebugEntity.DebugEntityType.NAMED); + } + + @Override + public IDebugEntity[] getIndexedVariables(long ID) { + return GlobalIDebugManagerHolder.debugManager.getVariables(ID, IDebugEntity.DebugEntityType.INDEXED); + } + + // ========== Breakpoint operations ========== + + @Override + public IBreakpoint[] bindBreakpoints(RawIdePath idePath, CanonicalServerAbsPath serverPath, int[] lines, String[] exprs) { + // Clear existing native breakpoints for this file + NativeDebuggerListener.clearBreakpointsForFile(serverPath.get()); + + // Add native breakpoints with optional conditions + IBreakpoint[] result = new Breakpoint[lines.length]; + for (int i = 0; i < lines.length; i++) { + String condition = (exprs != null && i < exprs.length) ? exprs[i] : null; + NativeDebuggerListener.addBreakpoint(serverPath.get(), lines[i], condition); + // Native breakpoints are always "bound" - no class loading dependency + result[i] = Breakpoint.Bound(lines[i], nextDapBreakpointID()); + } + + return result; + } + + @Override + public void clearAllBreakpoints() { + NativeDebuggerListener.clearAllBreakpoints(); + } + + // ========== Execution control ========== + + @Override + public void continue_(long threadID) { + NativeDebuggerListener.resumeNativeThread(threadID); + } + + @Override + public void continueAll() { + NativeDebuggerListener.resumeAllNativeThreads(); + } + + @Override + public void stepIn(long threadID) { + int currentDepth = getStackDepthForThread(threadID); + NativeDebuggerListener.startStepping(threadID, StepMode.STEP_INTO, currentDepth); + continue_(threadID); + } + + @Override + public void stepOver(long threadID) { + int currentDepth = getStackDepthForThread(threadID); + NativeDebuggerListener.startStepping(threadID, StepMode.STEP_OVER, currentDepth); + continue_(threadID); + } + + @Override + public void stepOut(long threadID) { + int currentDepth = getStackDepthForThread(threadID); + NativeDebuggerListener.startStepping(threadID, StepMode.STEP_OUT, currentDepth); + continue_(threadID); + } + + /** + * Get the current stack depth for a thread using native debugger frames. + */ + private int getStackDepthForThread(long threadID) { + IDebugFrame[] frames = getStackTrace(threadID); + return frames != null ? frames.length : 0; + } + + // ========== Debug utilities ========== + + @Override + public String dump(int dapVariablesReference) { + // Need a suspended thread to get PageContext for dump + // For native mode, we'd need to track suspended threads differently + return GlobalIDebugManagerHolder.debugManager.doDump(new ArrayList<>(), dapVariablesReference); + } + + @Override + public String dumpAsJSON(int dapVariablesReference) { + return GlobalIDebugManagerHolder.debugManager.doDumpAsJSON(new ArrayList<>(), dapVariablesReference); + } + + @Override + public String[] getTrackedCanonicalFileNames() { + // No class tracking in native mode + return new String[0]; + } + + @Override + public String[][] getBreakpointDetail() { + // TODO: Return native breakpoint details + return new String[0][]; + } + + @Override + public String getSourcePathForVariablesRef(int variablesRef) { + return GlobalIDebugManagerHolder.debugManager.getSourcePathForVariablesRef(variablesRef); + } + + @Override + public Either> evaluate(int frameID, String expr) { + return GlobalIDebugManagerHolder.debugManager.evaluate((Long)(long)frameID, expr); + } +} diff --git a/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java new file mode 100644 index 0000000..c1652a5 --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java @@ -0,0 +1,291 @@ +package luceedebug.coreinject.frame; + +import lucee.runtime.PageContext; +import lucee.runtime.PageContextImpl; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import luceedebug.*; +import luceedebug.coreinject.CfValueDebuggerBridge; +import luceedebug.coreinject.DebugEntity; +import luceedebug.coreinject.ValTracker; +import luceedebug.coreinject.CfValueDebuggerBridge.MarkerTrait; + +/** + * Adapter that wraps Lucee7's native DebuggerFrame to implement IDebugFrame. + * Uses reflection to access the new Lucee7 APIs so we can compile against older Lucee versions. + * + * This is used when Lucee's DEBUGGER_ENABLED=true and provides the CFML frame stack + * without requiring bytecode instrumentation. + */ +public class NativeDebugFrame implements IDebugFrame { + static private AtomicLong nextId = new AtomicLong( 0 ); + + // Reflection cache - initialized once + private static volatile Boolean nativeFrameSupportAvailable = null; + private static Field debuggerEnabledField = null; + private static Method getDebuggerFramesMethod = null; + private static Method getLineMethod = null; + private static Method setLineMethod = null; + private static Field localField = null; + private static Field argumentsField = null; + private static Field variablesField = null; + private static Field pageSourceField = null; + private static Field functionNameField = null; + private static Method getDisplayPathMethod = null; + + private final Object nativeFrame; // PageContextImpl.DebuggerFrame + private final PageContext pageContext; + private final ValTracker valTracker; + private final String sourceFilePath; + private final String functionName; + private final long id; + private final int depth; + + // Scope references from the native frame + private final Object local; // lucee.runtime.type.scope.Local + private final Object arguments; // lucee.runtime.type.scope.Argument + private final Object variables; // lucee.runtime.type.scope.Variables + + // lazy initialized on request for scopes + private LinkedHashMap scopes_ = null; + + private NativeDebugFrame( Object nativeFrame, PageContext pageContext, ValTracker valTracker, int depth ) throws Exception { + this.nativeFrame = nativeFrame; + this.pageContext = pageContext; + this.valTracker = valTracker; + this.id = nextId.incrementAndGet(); + this.depth = depth; + + // Extract fields using reflection + this.local = localField.get( nativeFrame ); + this.arguments = argumentsField.get( nativeFrame ); + this.variables = variablesField.get( nativeFrame ); + Object pageSource = pageSourceField.get( nativeFrame ); + this.sourceFilePath = (String) getDisplayPathMethod.invoke( pageSource ); + this.functionName = (String) functionNameField.get( nativeFrame ); + } + + @Override + public String getSourceFilePath() { + return sourceFilePath; + } + + @Override + public long getId() { + return id; + } + + @Override + public String getName() { + return functionName != null ? functionName : "??"; + } + + @Override + public int getDepth() { + return depth; + } + + @Override + public int getLine() { + try { + return (int) getLineMethod.invoke( nativeFrame ); + } catch ( Exception e ) { + return 0; + } + } + + @Override + public void setLine( int line ) { + try { + setLineMethod.invoke( nativeFrame, line ); + } catch ( Exception e ) { + // ignore + } + } + + private void checkedPutScopeRef( String name, Object scope ) { + if ( scope != null && scope instanceof Map ) { + var v = new MarkerTrait.Scope( (Map) scope ); + CfValueDebuggerBridge.pin( v ); + scopes_.put( name, new CfValueDebuggerBridge( valTracker, v ) ); + } + } + + private void lazyInitScopeRefs() { + if ( scopes_ != null ) { + return; + } + + scopes_ = new LinkedHashMap<>(); + + // Frame-specific scopes from native DebuggerFrame + checkedPutScopeRef( "local", local ); + checkedPutScopeRef( "arguments", arguments ); + checkedPutScopeRef( "variables", variables ); + + // Global scopes from PageContext - these are shared across frames + try { + checkedPutScopeRef( "application", pageContext.applicationScope() ); + } catch ( Throwable e ) { /* scope not available */ } + + try { + checkedPutScopeRef( "form", pageContext.formScope() ); + } catch ( Throwable e ) { /* scope not available */ } + + try { + checkedPutScopeRef( "request", pageContext.requestScope() ); + } catch ( Throwable e ) { /* scope not available */ } + + try { + if ( pageContext.getApplicationContext().isSetSessionManagement() ) { + checkedPutScopeRef( "session", pageContext.sessionScope() ); + } + } catch ( Throwable e ) { /* scope not available */ } + + try { + checkedPutScopeRef( "server", pageContext.serverScope() ); + } catch ( Throwable e ) { /* scope not available */ } + + try { + checkedPutScopeRef( "url", pageContext.urlScope() ); + } catch ( Throwable e ) { /* scope not available */ } + + // Try to get 'this' scope from variables if it's a ComponentScope + try { + if ( variables != null && variables.getClass().getName().equals( "lucee.runtime.ComponentScope" ) ) { + Method getComponentMethod = variables.getClass().getMethod( "getComponent" ); + Object component = getComponentMethod.invoke( variables ); + checkedPutScopeRef( "this", component ); + } + } catch ( Throwable e ) { /* scope not available */ } + } + + @Override + public IDebugEntity[] getScopes() { + lazyInitScopeRefs(); + IDebugEntity[] result = new DebugEntity[scopes_.size()]; + int i = 0; + for ( var kv : scopes_.entrySet() ) { + String name = kv.getKey(); + CfValueDebuggerBridge entityRef = kv.getValue(); + var entity = new DebugEntity(); + entity.name = name; + entity.namedVariables = entityRef.getNamedVariablesCount(); + entity.indexedVariables = entityRef.getIndexedVariablesCount(); + entity.expensive = true; + entity.variablesReference = entityRef.id; + result[i] = entity; + i += 1; + } + return result; + } + + /** + * Initialize reflection handles for Lucee7's native debugger frame support. + * Returns true if initialization succeeded (Lucee7 with DEBUGGER_ENABLED=true). + */ + private static synchronized boolean initReflection() { + if ( nativeFrameSupportAvailable != null ) { + return nativeFrameSupportAvailable; + } + + try { + Class pciClass = PageContextImpl.class; + + // Check if DEBUGGER_ENABLED field exists and is true + debuggerEnabledField = pciClass.getField( "DEBUGGER_ENABLED" ); + boolean enabled = debuggerEnabledField.getBoolean( null ); + if ( !enabled ) { + nativeFrameSupportAvailable = false; + return false; + } + + // Get the getDebuggerFrames method + getDebuggerFramesMethod = pciClass.getMethod( "getDebuggerFrames" ); + + // Get DebuggerFrame class (inner class of PageContextImpl) + Class debuggerFrameClass = Class.forName( "lucee.runtime.PageContextImpl$DebuggerFrame" ); + + // Get DebuggerFrame fields and methods + localField = debuggerFrameClass.getField( "local" ); + argumentsField = debuggerFrameClass.getField( "arguments" ); + variablesField = debuggerFrameClass.getField( "variables" ); + pageSourceField = debuggerFrameClass.getField( "pageSource" ); + functionNameField = debuggerFrameClass.getField( "functionName" ); + getLineMethod = debuggerFrameClass.getMethod( "getLine" ); + setLineMethod = debuggerFrameClass.getMethod( "setLine", int.class ); + + // Get PageSource.getDisplayPath method + Class pageSourceClass = Class.forName( "lucee.runtime.PageSource" ); + getDisplayPathMethod = pageSourceClass.getMethod( "getDisplayPath" ); + + nativeFrameSupportAvailable = true; + System.out.println( "[luceedebug] Native Lucee7 debugger frame support detected and enabled" ); + return true; + + } catch ( Throwable e ) { + // Lucee version doesn't have native debugger frame support + nativeFrameSupportAvailable = false; + return false; + } + } + + /** + * Check if native debugger frames are available in this Lucee version. + * Returns true if DEBUGGER_ENABLED is true in Lucee7+. + */ + public static boolean isNativeFrameSupportAvailable() { + return initReflection(); + } + + /** + * Get frames from Lucee's native debugger frame stack. + * Returns null if native frames are not available or empty. + */ + public static IDebugFrame[] getNativeFrames( PageContext pageContext, ValTracker valTracker ) { + if ( !isNativeFrameSupportAvailable() ) { + return null; + } + + try { + PageContextImpl pci = (PageContextImpl) pageContext; + Object[] nativeFrames = (Object[]) getDebuggerFramesMethod.invoke( pci ); + + if ( nativeFrames == null || nativeFrames.length == 0 ) { + return null; + } + + // Convert to IDebugFrame array, filtering frames with line 0 + ArrayList result = new ArrayList<>(); + + // Native frames are in push order (oldest first), DAP expects newest first + for ( int i = nativeFrames.length - 1; i >= 0; i-- ) { + Object nf = nativeFrames[i]; + int line = (int) getLineMethod.invoke( nf ); + + // Skip frames with line 0 (not yet stepped into) + if ( line == 0 ) { + continue; + } + + result.add( new NativeDebugFrame( nf, pageContext, valTracker, i ) ); + } + + if ( result.isEmpty() ) { + return null; + } + + return result.toArray( new IDebugFrame[0] ); + + } catch ( Throwable e ) { + System.err.println( "[luceedebug] Error getting native frames: " + e.getMessage() ); + return null; + } + } +} diff --git a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java new file mode 100644 index 0000000..f68d8be --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java @@ -0,0 +1,146 @@ +package luceedebug.extension; + +import java.lang.reflect.Method; + +import lucee.loader.engine.CFMLEngineFactory; +import lucee.runtime.config.Config; + +import luceedebug.DapServer; +import luceedebug.coreinject.NativeLuceeVm; +import luceedebug.coreinject.NativeDebuggerListener; + +/** + * Extension startup hook - instantiated by Lucee when the extension loads. + * Uses Lucee's startup-hook mechanism (manifest attribute). + * + * Native-only mode: requires Lucee 7.1+ with DebuggerRegistry API. + * No JDWP, no Java agent, no bytecode instrumentation. + */ +public class ExtensionActivator { + private static NativeLuceeVm luceeVm; + + /** + * Constructor called by Lucee's startup-hook mechanism. + * Lucee passes the Config object automatically. + */ + public ExtensionActivator(Config luceeConfig) { + System.out.println("[luceedebug] Extension activating via startup-hook"); + + // Get debug port from environment - if not set, debugger is disabled + int debugPort = getDebuggerPort(); + if (debugPort < 0) { + System.out.println("[luceedebug] Debugger not enabled"); + System.out.println("[luceedebug] Set LUCEE_DEBUGGER_PORT= to enable"); + return; + } + + // Get classloaders - extension's loader has our classes, Lucee's has core interfaces + ClassLoader extensionLoader = this.getClass().getClassLoader(); + ClassLoader luceeLoader = luceeConfig.getClass().getClassLoader(); + + // Register debugger listener with Lucee's DebuggerRegistry + if (!registerNativeDebuggerListener(luceeLoader, extensionLoader)) { + System.out.println("[luceedebug] Failed to register debugger listener - extension disabled"); + return; + } + + // Determine filesystem case sensitivity from Lucee's config location + String configPath = luceeConfig.getConfigDir().getAbsolutePath(); + boolean fsCaseSensitive = luceedebug.Config.checkIfFileSystemIsCaseSensitive(configPath); + + // Create luceedebug config + luceedebug.Config config = new luceedebug.Config(fsCaseSensitive); + + // Create NativeLuceeVm + luceeVm = new NativeLuceeVm(config); + + // Start DAP server in background thread (createForSocket blocks forever) + final int port = debugPort; + new Thread(() -> { + DapServer.createForSocket(luceeVm, config, "localhost", port); + }, "luceedebug-dap-server").start(); + + System.out.println("[luceedebug] DAP server starting on localhost:" + debugPort); + } + + /** + * Register native debugger listener using cross-classloader proxy. + * DebuggerRegistry and DebuggerListener are in Lucee's core (luceeLoader). + * NativeDebuggerListener is in our extension bundle (extensionLoader). + */ + private boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoader extensionLoader) { + try { + // Load Lucee core classes + Class registryClass = luceeLoader.loadClass("lucee.runtime.debug.DebuggerRegistry"); + Class listenerInterface = luceeLoader.loadClass("lucee.runtime.debug.DebuggerListener"); + Class pageContextClass = luceeLoader.loadClass("lucee.runtime.PageContext"); + + // Load our implementation from extension bundle + Class nativeListenerClass = extensionLoader.loadClass( + "luceedebug.coreinject.NativeDebuggerListener"); + + // Cache method lookups + final Method onSuspendMethod = nativeListenerClass.getMethod("onSuspend", + pageContextClass, String.class, int.class, String.class); + final Method onResumeMethod = nativeListenerClass.getMethod("onResume", pageContextClass); + final Method shouldSuspendMethod = nativeListenerClass.getMethod("shouldSuspend", + pageContextClass, String.class, int.class); + + // Create proxy in Lucee's classloader, delegating to extension's implementation + Object listenerProxy = java.lang.reflect.Proxy.newProxyInstance( + luceeLoader, + new Class[] { listenerInterface }, + (proxy, method, args) -> { + switch (method.getName()) { + case "onSuspend": return onSuspendMethod.invoke(null, args); + case "onResume": return onResumeMethod.invoke(null, args); + case "shouldSuspend": return shouldSuspendMethod.invoke(null, args); + default: throw new UnsupportedOperationException("Unknown method: " + method.getName()); + } + } + ); + + // Register with Lucee + Method setListener = registryClass.getMethod("setListener", listenerInterface); + setListener.invoke(null, listenerProxy); + + System.out.println("[luceedebug] Registered native debugger listener"); + return true; + } catch (ClassNotFoundException e) { + System.out.println("[luceedebug] DebuggerRegistry not found - requires Lucee 7.1+"); + return false; + } catch (Throwable e) { + System.out.println("[luceedebug] Failed to register listener: " + e.getMessage()); + e.printStackTrace(); + return false; + } + } + + /** + * Get debugger port from environment/system property. + * Returns -1 if not set (debugger disabled). + */ + private static int getDebuggerPort() { + String port = getSystemPropOrEnvVar("lucee.debugger.port"); + if (port == null || port.isEmpty()) { + return -1; // disabled + } + return CFMLEngineFactory.getInstance().getCastUtil().toIntValue(port, -1); + } + + /** + * Get system property or environment variable. + * System property takes precedence. Env var name is derived from property name + * by uppercasing and replacing dots with underscores. + */ + private static String getSystemPropOrEnvVar(String propertyName) { + // Try system property first + String value = System.getProperty(propertyName); + if (value != null && !value.isEmpty()) { + return value; + } + // Try env var (lucee.debugger.port -> LUCEE_DEBUGGER_PORT) + String envName = propertyName.toUpperCase().replace('.', '_'); + return System.getenv(envName); + } +} diff --git a/profiling/DapClient.cfc b/profiling/DapClient.cfc new file mode 100644 index 0000000..962adcb --- /dev/null +++ b/profiling/DapClient.cfc @@ -0,0 +1,338 @@ +/** + * DAP (Debug Adapter Protocol) client for testing luceedebug. + * + * Usage: + * dap = new DapClient(); + * dap.connect( "localhost", 10000 ); + * dap.initialize(); + * dap.setBreakpoints( "/path/to/file.cfm", [ 10, 20 ] ); + * dap.configurationDone(); + * // ... trigger breakpoint via HTTP ... + * event = dap.waitForEvent( "stopped", 5000 ); + * stack = dap.stackTrace( event.body.threadId ); + * dap.continueThread( event.body.threadId ); + * dap.disconnect(); + */ +component { + + variables.socket = javacast( "null", 0 ); + variables.inputStream = javacast( "null", 0 ); + variables.outputStream = javacast( "null", 0 ); + variables.seq = 0; + variables.eventQueue = []; + variables.pendingResponses = {}; + variables.debug = false; + + public function init( boolean debug = false ) { + variables.debug = arguments.debug; + return this; + } + + // ========== Connection ========== + + public function connect( required string host, required numeric port ) { + variables.socket = createObject( "java", "java.net.Socket" ).init( arguments.host, arguments.port ); + variables.socket.setSoTimeout( 100 ); // 100ms read timeout for polling + variables.inputStream = variables.socket.getInputStream(); + variables.outputStream = variables.socket.getOutputStream(); + log( "Connected to #arguments.host#:#arguments.port#" ); + } + + public function disconnect() { + if ( !isNull( variables.socket ) ) { + variables.socket.close(); + variables.socket = javacast( "null", 0 ); + log( "Disconnected" ); + } + } + + public boolean function isConnected() { + return !isNull( variables.socket ) && variables.socket.isConnected() && !variables.socket.isClosed(); + } + + // ========== DAP Commands ========== + + public struct function initialize() { + var response = sendRequest( "initialize", { + "clientID": "cfml-dap-test", + "adapterID": "luceedebug", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true + } ); + return response; + } + + public struct function setBreakpoints( required string path, required array lines ) { + var breakpoints = []; + for ( var line in arguments.lines ) { + breakpoints.append( { "line": line } ); + } + var response = sendRequest( "setBreakpoints", { + "source": { "path": arguments.path }, + "breakpoints": breakpoints + } ); + return response; + } + + public struct function configurationDone() { + return sendRequest( "configurationDone", {} ); + } + + public struct function threads() { + return sendRequest( "threads", {} ); + } + + public struct function stackTrace( required numeric threadId, numeric startFrame = 0, numeric levels = 20 ) { + return sendRequest( "stackTrace", { + "threadId": arguments.threadId, + "startFrame": arguments.startFrame, + "levels": arguments.levels + } ); + } + + public struct function scopes( required numeric frameId ) { + return sendRequest( "scopes", { + "frameId": arguments.frameId + } ); + } + + public struct function getVariables( required numeric variablesReference ) { + return sendRequest( "variables", { + "variablesReference": arguments.variablesReference + } ); + } + + public struct function continueThread( required numeric threadId ) { + return sendRequest( "continue", { + "threadId": arguments.threadId + } ); + } + + public struct function stepOver( required numeric threadId ) { + return sendRequest( "next", { + "threadId": arguments.threadId + } ); + } + + public struct function stepIn( required numeric threadId ) { + return sendRequest( "stepIn", { + "threadId": arguments.threadId + } ); + } + + public struct function stepOut( required numeric threadId ) { + return sendRequest( "stepOut", { + "threadId": arguments.threadId + } ); + } + + public struct function evaluate( required numeric frameId, required string expression ) { + return sendRequest( "evaluate", { + "frameId": arguments.frameId, + "expression": arguments.expression, + "context": "watch" + } ); + } + + public struct function dapDisconnect() { + return sendRequest( "disconnect", {} ); + } + + // ========== Event Handling ========== + + /** + * Wait for a specific event type. + * @eventType The event type to wait for (e.g., "stopped", "thread") + * @timeoutMs Maximum time to wait in milliseconds + * @return The event struct, or throws if timeout + */ + public struct function waitForEvent( required string eventType, numeric timeoutMs = 5000 ) { + var startTime = getTickCount(); + + while ( getTickCount() - startTime < arguments.timeoutMs ) { + // Check queued events first + for ( var i = 1; i <= variables.eventQueue.len(); i++ ) { + if ( variables.eventQueue[ i ].event == arguments.eventType ) { + var event = variables.eventQueue[ i ]; + variables.eventQueue.deleteAt( i ); + return event; + } + } + + // Poll for new messages + pollMessages(); + sleep( 10 ); + } + + throw( type="DapClient.Timeout", message="Timeout waiting for event: #arguments.eventType#" ); + } + + /** + * Check if any events of a type are queued. + */ + public boolean function hasEvent( required string eventType ) { + pollMessages(); + for ( var event in variables.eventQueue ) { + if ( event.event == arguments.eventType ) { + return true; + } + } + return false; + } + + /** + * Get all queued events (clears the queue). + */ + public array function drainEvents() { + pollMessages(); + var events = variables.eventQueue; + variables.eventQueue = []; + return events; + } + + // ========== Protocol Implementation ========== + + private struct function sendRequest( required string command, required struct args ) { + var requestSeq = ++variables.seq; + var request = { + "seq": requestSeq, + "type": "request", + "command": arguments.command, + "arguments": arguments.args + }; + + sendMessage( request ); + + // Wait for response with matching request_seq + var startTime = getTickCount(); + var timeout = 10000; // 10 second timeout for responses + + while ( getTickCount() - startTime < timeout ) { + pollMessages(); + + if ( variables.pendingResponses.keyExists( requestSeq ) ) { + var response = variables.pendingResponses[ requestSeq ]; + variables.pendingResponses.delete( requestSeq ); + + if ( !response.success ) { + throw( type="DapClient.Error", message="DAP error: #response.message ?: 'unknown'#" ); + } + + return response; + } + + sleep( 10 ); + } + + throw( type="DapClient.Timeout", message="Timeout waiting for response to: #arguments.command#" ); + } + + private void function sendMessage( required struct message ) { + var json = serializeJSON( arguments.message ); + var bytes = json.getBytes( "UTF-8" ); + var header = "Content-Length: #bytes.length#\r\n\r\n"; + + log( ">>> #json#" ); + + variables.outputStream.write( header.getBytes( "UTF-8" ) ); + variables.outputStream.write( bytes ); + variables.outputStream.flush(); + } + + private void function pollMessages() { + try { + while ( variables.inputStream.available() > 0 ) { + var message = readMessage(); + if ( !isNull( message ) ) { + handleMessage( message ); + } + } + } catch ( any e ) { + // Socket timeout is expected, ignore + if ( !e.message contains "timed out" && !e.message contains "Read timed out" ) { + rethrow; + } + } + } + + private any function readMessage() { + // Read headers until empty line + var headers = {}; + var headerLine = readLine(); + + while ( headerLine != "" ) { + var colonPos = headerLine.find( ":" ); + if ( colonPos > 0 ) { + var key = headerLine.left( colonPos - 1 ).trim(); + var value = headerLine.mid( colonPos + 1, headerLine.len() ).trim(); + headers[ key ] = value; + } + headerLine = readLine(); + } + + if ( !headers.keyExists( "Content-Length" ) ) { + return javacast( "null", 0 ); + } + + // Read body + var contentLength = val( headers[ "Content-Length" ] ); + var bodyBytes = createObject( "java", "java.io.ByteArrayOutputStream" ).init(); + var remaining = contentLength; + + while ( remaining > 0 ) { + var b = variables.inputStream.read(); + if ( b == -1 ) { + throw( type="DapClient.Error", message="Unexpected end of stream" ); + } + bodyBytes.write( b ); + remaining--; + } + + var json = bodyBytes.toString( "UTF-8" ); + log( "<<< #json#" ); + + return deserializeJSON( json ); + } + + private string function readLine() { + var line = createObject( "java", "java.lang.StringBuilder" ).init(); + var prevChar = 0; + + while ( true ) { + var b = variables.inputStream.read(); + if ( b == -1 ) { + break; + } + var c = chr( b ); + if ( c == chr( 10 ) ) { // LF + break; + } + if ( c != chr( 13 ) ) { // Skip CR + line.append( c ); + } + } + + return line.toString(); + } + + private void function handleMessage( required struct message ) { + switch ( arguments.message.type ) { + case "response": + variables.pendingResponses[ arguments.message.request_seq ] = arguments.message; + break; + case "event": + variables.eventQueue.append( arguments.message ); + break; + default: + log( "Unknown message type: #arguments.message.type#" ); + } + } + + private void function log( required string msg ) { + if ( variables.debug ) { + systemOutput( "[DapClient] #arguments.msg#", true ); + } + } + +} diff --git a/profiling/NATIVE_DEBUGGING_TESTING.md b/profiling/NATIVE_DEBUGGING_TESTING.md new file mode 100644 index 0000000..8b0eca9 --- /dev/null +++ b/profiling/NATIVE_DEBUGGING_TESTING.md @@ -0,0 +1,178 @@ +# Native Debugging Testing Plan + +Testing native stepping for Lucee7+ debugger without JDWP. + +## Test Infrastructure + +### Option 1: Local Tomcat (Recommended for interactive testing) + +Use the existing Lucee7 Tomcat setup at `D:\lucee7\tomcat`: + +```cmd +rem Start Tomcat with luceedebug agent +cd D:\lucee7\tomcat\bin +set LUCEE_DEBUGGER_ENABLED=true +set lucee_logging_force_level=trace +set lucee_logging_force_appender=console +startup.bat +``` + +Then connect VS Code debugger to port 10000 and test stepping manually. + +### Option 2: Script-runner (For automated headless tests) + +Use script-runner for headless CFML execution: + +```cmd +ant -buildfile "D:\work\script-runner" ^ + -DluceeJar="D:\work\lucee7\loader\target\lucee-7.1.0.7-ALPHA.jar" ^ + -Dwebroot="D:\work\lucee-extensions\luceedebug\profiling" ^ + -Dexecute="test-debugger-frames.cfm" ^ + -Djdwp="true" ^ + -DjdwpPort="9999" ^ + -DjavaAgent="D:\work\lucee-extensions\luceedebug\luceedebug\build\libs\luceedebug-2.0.15.jar" ^ + -DjavaAgentArgs="jdwpHost=localhost,jdwpPort=9999,debugHost=0.0.0.0,debugPort=10000,jarPath=..." +``` + +**Limitation**: Script-runner is headless (no HTTP server), so DAP tests that require triggering breakpoints via HTTP won't work. + +### Option 3: Two-instance DAP testing + +For full DAP protocol testing (breakpoints, stepping via DAP commands): + +1. **Instance A (debuggee)**: Lucee with luceedebug, HTTP server on 8888, DAP on 10000 +2. **Instance B (test runner)**: Lucee WITHOUT debugger, runs test script + +The test script (Instance B) connects to DAP (10000), sets breakpoints, triggers HTTP requests to Instance A (8888), and verifies stepping behavior. + +## Test Files + +| File | Purpose | +|------|---------| +| `test-debugger-frames.cfm` | Tests native `getDebuggerFrames()` API | +| `test-breakpoint-bif.cfm` | Tests `breakpoint()` BIF | +| `dap-target.cfm` | Simple target for DAP breakpoint tests | +| `dap-step-target.cfm` | Target with nested functions for stepping tests | +| `test-dap-breakpoint.cfm` | DAP breakpoint test (needs 2 instances) | +| `test-dap-stepping.cfm` | DAP stepping test (needs 2 instances) | +| `DapClient.cfc` | CFML DAP protocol client | + +## Test Scenarios + +### 1. Native Frame API (no DAP) + +**Script**: `test-debugger-frames.cfm` +**Tests**: + +- [x] `DEBUGGER_ENABLED` flag detection +- [x] `getDebuggerFrames()` returns correct frame count +- [x] Frame contains function name, file path, locals, arguments +- [x] Nested function calls show correct stack + +### 2. Breakpoint BIF (no DAP) + +**Script**: `test-breakpoint-bif.cfm` +**Tests**: + +- [ ] `breakpoint()` suspends execution when debugger enabled +- [ ] `breakpoint(false)` does not suspend +- [ ] `breakpoint(true, "label")` shows label in debugger +- [ ] `breakpoint()` is no-op when debugger disabled + +### 3. DAP Breakpoints + +**Script**: `test-dap-breakpoint.cfm` +**Requires**: Two Lucee instances +**Tests**: + +- [ ] DAP connection and initialization +- [ ] Set breakpoints via `setBreakpoints` command +- [ ] Breakpoint hit sends `stopped` event +- [ ] Stack trace shows correct file/line +- [ ] Variables visible in scopes +- [ ] Continue resumes execution + +### 4. DAP Stepping + +**Script**: `test-dap-stepping.cfm` +**Requires**: Two Lucee instances +**Tests**: + +- [ ] **Step Over** (`next`): Executes function call, stops on next line +- [ ] **Step In** (`stepIn`): Enters function, stops on first line +- [ ] **Step Out** (`stepOut`): Runs to function return, stops in caller +- [ ] Step + breakpoint: Stepping stops at breakpoints too +- [ ] Nested stepping: Step in/out through multiple call levels + +## Manual Testing with VS Code + +1. Start Lucee7 Tomcat with luceedebug agent +2. Open VS Code, create launch.json: + ```json + { + "type": "cfml", + "request": "attach", + "name": "Attach to Lucee", + "hostName": "localhost", + "port": 10000 + } + ``` +3. Set breakpoint in a .cfm file +4. Trigger the page via browser +5. Test stepping buttons: Step Over (F10), Step In (F11), Step Out (Shift+F11) + +## Expected Stepping Behavior + +Given this code: +```cfml +function inner() { + var x = 1; // line 2 + return x; // line 3 +} + +function outer() { + var a = 0; // line 7 + var b = inner(); // line 8 - step in goes to line 2 + var c = b + 1; // line 9 - step over from line 8 goes here + return c; // line 10 +} + +result = outer(); // line 13 - breakpoint here +done = true; // line 14 +``` + +| Action | From | To | Stack Depth Change | +|--------|------|----|--------------------| +| Step Over | line 13 | line 14 | same | +| Step In | line 13 | line 7 | +1 | +| Step In | line 8 | line 2 | +1 | +| Step Over | line 8 | line 9 | same | +| Step Out | line 2 | line 9 | -1 | +| Step Out | line 7 | line 14 | -1 | + +## Debugging Test Failures + +### Enable trace logging + +```cmd +set lucee_logging_force_level=trace +set lucee_logging_force_appender=console +``` + +### Check luceedebug output + +Look for `[luceedebug]` prefixed messages: + +- `Registered native debugger listener` - listener registered OK +- `Native-only mode: true` - using native breakpoints +- `Added native breakpoint: /path/file.cfm:10` - breakpoint set +- `Native suspend: thread=123 file=... line=10` - breakpoint hit +- `Start stepping: thread=123 mode=STEP_OVER depth=3` - stepping started +- `Resuming native thread: 123` - continue/step executed + +### Common issues + +1. **No suspend on breakpoint**: Check `LUCEE_DEBUGGER_ENABLED=true` +2. **DebuggerRegistry not found**: Lucee version too old (need 7.1+) +3. **Stepping doesn't stop**: Check `shouldSuspend()` logic +4. **Wrong line after step**: Check stack depth calculation diff --git a/profiling/README.md b/profiling/README.md new file mode 100644 index 0000000..e3e2abc --- /dev/null +++ b/profiling/README.md @@ -0,0 +1,223 @@ +# luceedebug Profiling + +This directory contains tools for profiling luceedebug performance using Java Flight Recorder (JFR). + +## Findings Summary (2025-12-03) + +### Performance Overhead + +Benchmark results with 100,000 iterations on Lucee 7.1.0.7-ALPHA: + +| Benchmark | Baseline | With Agent | Overhead | +|-----------|----------|------------|----------| +| Simple function calls | 111ms | 175ms | **+58%** | +| Multi-line function | 99ms | 155ms | **+57%** | +| Nested function calls | 132ms | 236ms | **+79%** | +| Recursive calls (depth=10) | 373ms | 540ms | **+45%** | +| Mixed workload | 323ms | 593ms | **+84%** | + +**Key observation:** ~50-80% overhead with agent loaded but NOT actively debugging. + +### Real-World Test: lucee-spreadsheet + +| Test | Baseline | With Agent | Overhead | +|------|----------|------------|----------| +| Spreadsheet test suite | 20s | 25s | **+25%** | + +The real-world overhead is lower (~25%) because: + +- Synthetic benchmarks hammer function calls exclusively +- Real code spends time in I/O, Java libraries, etc. (not instrumented) +- Class loading overhead is amortised over more work + +### Hot Methods (from JFR) + +**Synthetic benchmark** - most frequently sampled luceedebug methods: + +1. `luceedebug_stepNotificationEntry_step` - Called on every CFML line +2. `getTopmostFrame` - Called from step notification +3. `maybeUpdateTopmostFrame` - Called from step notification +4. `maybe_pushCfFrame_worker` - Called on function entry +5. `pushCfFrame` - Called on function entry +6. `popCfFrame` - Called on function exit + +**Real-world (lucee-spreadsheet)** - different profile: + +1. **ASM ClassReader** (75+ samples) - bytecode transformation at class load +2. **udfCall wrappers** (108 samples) - function call wrappers +3. **pushCfFrame/popCfFrame** (31 samples) - frame management +4. **step notification** (5 samples) - much lower than synthetic + +In real apps, class loading overhead dominates initially, then function wrappers. + +### JFR Detailed Analysis (lucee-spreadsheet) + +| Metric | Baseline | With Agent | Difference | +|--------|----------|------------|------------| +| Duration | 20s | 25s | +5s (+25%) | +| ExecutionSamples | 392 | 526 | +134 | +| ClassLoad events | 10,784 | 11,227 | +443 extra classes | +| TLAB allocations | 3,050 | 7,457 | **+4,407 (+144%)** | +| GC pauses | 47 | 75 | +28 (+60%) | +| GC count | 42 | 64 | +22 (+52%) | + +**luceedebug-specific allocations** (not present in baseline): + +- 167× `Frame` - one per CFML function call +- 165× `Frame$FrameContext` - accompanies each Frame +- 360× `Long` (boxing from hash lookups) +- 350× `ArrayList$Itr` (iterator allocations) +- 120× `ConcurrentHashMap$Node` (hash map operations) + +**JDWP overhead** (from having debug port open): + +- 151× `ClassLoaderReference$VisibleClasses$ClassInfo` +- 106× `EventSetImpl` +- 104× `ClassTypeImpl` +- 74× `ClassPrepare` events + +**Lock contention**: No luceedebug-related lock contention detected. All `JavaMonitorEnter` events were in `SecureRandom` (unrelated to luceedebug). + +**Key insight**: Memory pressure is significant - 144% more TLAB allocations leads to 52% more GC cycles. Frame/FrameContext allocations per function call are a prime optimisation target. + +### JIT Inlining Analysis + +From `jdk.CompilerInlining` events, checking which hot methods get inlined: + +| Method | Call site | Inlined? | Reason | +|--------|-----------|----------|--------| +| `Thread.currentThread()` | maybe_pushCfFrame_worker | ✅ Yes | intrinsic | +| `ConcurrentMap.get()` | maybe_pushCfFrame_worker | ❌ No | "no static binding" (interface call) | +| `ArrayList.size()` | maybe_pushCfFrame_worker | ✅ Yes | inline | +| `Thread.currentThread()` | stepNotificationEntry_step | ✅ Yes | intrinsic | +| `ConcurrentHashMap.get()` | stepNotificationEntry_step | ❌ No | "no static binding" | + +**Key insight**: The `ConcurrentMap.get()` calls cannot be inlined because they're interface method calls. This happens on every function entry (`maybe_pushCfFrame_worker`) and every line (`stepNotificationEntry_step`). Using `ThreadLocal` instead of `ConcurrentHashMap` lookups for frame stacks would allow better JIT optimisation. + +### Stack Overflow Issue + +**IMPORTANT:** luceedebug can cause StackOverflowError on complex codebases. + +The instrumentation wraps every function call: + +``` +original: udfCall() -> actual code +with luceedebug: udfCall() -> udfCall__luceedebug__udfCall() -> actual code +``` + +This doubles stack frame usage. The lucee-docs build with Pygments syntax highlighting hits the recursion limit and fails with StackOverflowError when luceedebug is attached. + +**Workaround:** Increase stack size with `-Xss2m` or similar. + +### Optimization Priorities + +Based on profiling, the highest-impact optimizations would be: + +1. **Fast-path for step notification** - Add `stepRequestByThread.isEmpty()` check to exit early when not stepping +2. **Pre-sized collections** - ArrayList for frame stacks, HashMap for line maps +3. **ThreadLocal stepping flag** - Avoid hash lookups entirely when not stepping + +See `PERFORMANCE_PLAN.md` in project root for detailed optimization plan. + +--- + +## Overview + +luceedebug instruments every CFML function call and line execution. To understand the overhead, we need to profile: + +1. **Baseline** - Lucee running WITHOUT luceedebug +2. **Attached** - Lucee running WITH luceedebug (debugger not connected) +3. **Connected** - Lucee running WITH luceedebug AND debugger connected +4. **Stepping** - Lucee running WITH active step-through debugging + +## Files + +- `benchmark.cfm` - CFML script that stresses the hot paths (function calls, line stepping) +- `profile-baseline.bat` - Run benchmark without luceedebug (baseline) +- `profile-with-agent.bat` - Run benchmark with luceedebug agent loaded +- `profile-baseline-docs.bat` - Run lucee-docs build without luceedebug +- `profile-with-agent-docs.bat` - Run lucee-docs build with luceedebug (may fail with StackOverflow) +- `profile-baseline-spreadsheet.bat` - Run lucee-spreadsheet tests without luceedebug +- `profile-with-agent-spreadsheet.bat` - Run lucee-spreadsheet tests with luceedebug +- `compare-results.bat` - Compare baseline vs with-agent results + +## Prerequisites + +1. Build luceedebug: + + ```cmd + cd d:\work\lucee-extensions\luceedebug\luceedebug + gradlew shadowJar + ``` + +2. Ensure script-runner is available at `D:\work\script-runner` + +3. (Optional) For docs build tests, ensure lucee-docs is at `D:\work\lucee-docs` + +## Running the Profiles + +### 1. Baseline (No luceedebug) + +```cmd +profile-baseline.bat +``` + +This runs the benchmark with just Lucee, no debugger. Establishes the baseline performance. + +### 2. With luceedebug Agent + +```cmd +profile-with-agent.bat +``` + +This runs with luceedebug loaded but no debugger connected. Shows the passive overhead of having the agent attached. + +### 3. Compare Results + +```cmd +compare-results.bat +``` + +Shows side-by-side timing comparison. + +## Analysing JFR Results + +Open the `.jfr` files in JDK Mission Control (JMC) or use command line: + +```cmd +rem Summary +jfr summary output\with-agent.jfr + +rem Hot methods +jfr print --events jdk.ExecutionSample output\with-agent.jfr + +rem Count luceedebug methods in samples +jfr print --events jdk.ExecutionSample output\with-agent.jfr | grep -oE "luceedebug[^)]*" | sort | uniq -c | sort -rn + +rem Convert to JSON for scripting +jfr print --json output\with-agent.jfr > with-agent.json +``` + +### Key Things to Look For + +1. **CPU Hotspots** - Which methods consume the most CPU? + - Look for `luceedebug.*` methods in the flame graph + - Compare time spent in `pushCfFrame`/`popCfFrame` vs actual CFML execution + +2. **Lock Contention** - Are there synchronization bottlenecks? + - Check `jdk.JavaMonitorEnter` events + - Look for `ValTracker`, `DebugManager` lock waits + +3. **Allocations** - Memory pressure from debugging? + - Check `jdk.ObjectAllocationInNewTLAB` and `jdk.ObjectAllocationOutsideTLAB` + - Look for `Optional`, `WeakReference`, `ArrayList` allocations in hot paths + +4. **GC Impact** - Is the debugger causing more GC? + - Compare GC pause times and frequency between baseline and with-agent runs + +## Notes + +- The benchmark uses `systemOutput()` not `writeOutput()` for script-runner compatibility +- ITERATIONS can be adjusted in `benchmark.cfm` for longer/shorter runs +- For stepping overhead analysis, you'd need to manually step through with VS Code +- Real-world workloads (like lucee-docs) may hit stack limits due to doubled frame usage diff --git a/profiling/benchmark.cfm b/profiling/benchmark.cfm new file mode 100644 index 0000000..33e5dfb --- /dev/null +++ b/profiling/benchmark.cfm @@ -0,0 +1,115 @@ + +/** + * luceedebug Performance Benchmark + * + * This script stresses the hot paths in luceedebug: + * - Function calls (pushCfFrame/popCfFrame) + * - Line stepping (luceedebug_stepNotificationEntry_step) + * - Nested calls and deep stacks + * + * Run with JFR enabled to profile overhead. + */ + +// Configuration +ITERATIONS = 100000; +NESTED_DEPTH = 10; + +// Simple function call - minimal overhead baseline +function simpleFunc( n ) { + return n + 1; +} + +// Recursive function - tests deep call stacks +function recursiveFunc( depth ) { + if ( depth <= 0 ) { + return 0; + } + return 1 + recursiveFunc( depth - 1 ); +} + +// Function with multiple lines - stresses line stepping +function multiLineFunc( n ) { + var a = n; + var b = a + 1; + var c = b + 2; + var d = c + 3; + var e = d + 4; + var f = e + 5; + var g = f + 6; + var h = g + 7; + var i = h + 8; + var j = i + 9; + return j; +} + +// Function that calls other functions - nested calls +function callerFunc( n ) { + var x = simpleFunc( n ); + var y = multiLineFunc( x ); + return y; +} + +// Warmup +systemOutput( "Warming up...", true ); +for ( i = 1; i <= 1000; i++ ) { + simpleFunc( i ); + multiLineFunc( i ); + callerFunc( i ); +} + +// Benchmark: Simple function calls +systemOutput( "", true ); +systemOutput( "=== Benchmark: Simple function calls (#ITERATIONS# iterations) ===", true ); +start = getTickCount(); +for ( i = 1; i <= ITERATIONS; i++ ) { + simpleFunc( i ); +} +elapsed = getTickCount() - start; +systemOutput( "Time: #elapsed#ms (#ITERATIONS / elapsed * 1000# calls/sec)", true ); + +// Benchmark: Multi-line function +systemOutput( "", true ); +systemOutput( "=== Benchmark: Multi-line function (#ITERATIONS# iterations) ===", true ); +start = getTickCount(); +for ( i = 1; i <= ITERATIONS; i++ ) { + multiLineFunc( i ); +} +elapsed = getTickCount() - start; +systemOutput( "Time: #elapsed#ms", true ); + +// Benchmark: Nested calls +systemOutput( "", true ); +systemOutput( "=== Benchmark: Nested function calls (#ITERATIONS# iterations) ===", true ); +start = getTickCount(); +for ( i = 1; i <= ITERATIONS; i++ ) { + callerFunc( i ); +} +elapsed = getTickCount() - start; +systemOutput( "Time: #elapsed#ms", true ); + +// Benchmark: Deep recursion +systemOutput( "", true ); +systemOutput( "=== Benchmark: Recursive calls (depth=#NESTED_DEPTH#, #ITERATIONS# iterations) ===", true ); +start = getTickCount(); +for ( i = 1; i <= ITERATIONS; i++ ) { + recursiveFunc( NESTED_DEPTH ); +} +elapsed = getTickCount() - start; +systemOutput( "Time: #elapsed#ms", true ); + +// Benchmark: Mixed workload +systemOutput( "", true ); +systemOutput( "=== Benchmark: Mixed workload (#ITERATIONS# iterations) ===", true ); +start = getTickCount(); +for ( i = 1; i <= ITERATIONS; i++ ) { + simpleFunc( i ); + multiLineFunc( i ); + callerFunc( i ); + recursiveFunc( 5 ); +} +elapsed = getTickCount() - start; +systemOutput( "Time: #elapsed#ms", true ); + +systemOutput( "", true ); +systemOutput( "Benchmark complete!", true ); + diff --git a/profiling/compare-results.bat b/profiling/compare-results.bat new file mode 100644 index 0000000..8fbb4ea --- /dev/null +++ b/profiling/compare-results.bat @@ -0,0 +1,55 @@ +@echo off +setlocal + +rem ============================================================ +rem Compare baseline vs with-agent profiling results +rem ============================================================ + +set OUTPUT_DIR=%~dp0output + +echo. +echo ============================================================ +echo PERFORMANCE COMPARISON +echo ============================================================ +echo. + +echo --- BASELINE (no luceedebug) --- +if exist "%OUTPUT_DIR%\baseline-output.txt" ( + type "%OUTPUT_DIR%\baseline-output.txt" | findstr /C:"Time:" +) else ( + echo No baseline results found. Run profile-baseline.bat first. +) + +echo. +echo --- WITH AGENT (luceedebug loaded) --- +if exist "%OUTPUT_DIR%\with-agent-output.txt" ( + type "%OUTPUT_DIR%\with-agent-output.txt" | findstr /C:"Time:" +) else ( + echo No agent results found. Run profile-with-agent.bat first. +) + +echo. +echo ============================================================ +echo JFR FILES +echo ============================================================ +echo. + +if exist "%OUTPUT_DIR%\baseline.jfr" ( + echo Baseline: %OUTPUT_DIR%\baseline.jfr +) else ( + echo Baseline: NOT FOUND +) + +if exist "%OUTPUT_DIR%\with-agent.jfr" ( + echo With Agent: %OUTPUT_DIR%\with-agent.jfr +) else ( + echo With Agent: NOT FOUND +) + +echo. +echo To analyse in JDK Mission Control: +echo jmc -open "%OUTPUT_DIR%\baseline.jfr" +echo jmc -open "%OUTPUT_DIR%\with-agent.jfr" +echo. + +endlocal diff --git a/profiling/dap-step-target.cfm b/profiling/dap-step-target.cfm new file mode 100644 index 0000000..5f3db1c --- /dev/null +++ b/profiling/dap-step-target.cfm @@ -0,0 +1,24 @@ + +// Target script for DAP stepping tests. +// Tests stepIn, stepOver, stepOut by calling nested functions. + +function innerFunc( required string value ) { + var inner1 = "inner: #arguments.value#"; // line 6 + var inner2 = inner1 & "!"; // line 7 + return inner2; // line 8 +} + +function outerFunc( required string name ) { + var before = "before"; // line 12 + var result = innerFunc( arguments.name ); // line 13 - stepIn goes here + var after = "after: #result#"; // line 14 - stepOver stays here + return after; // line 15 +} + +// Main execution +var startLine = "start"; // line 19 +var output = outerFunc( "test" ); // line 20 - breakpoint here +var endLine = "end: #output#"; // line 21 + +systemOutput( "dap-step-target.cfm completed: #endLine#", true ); + diff --git a/profiling/dap-target.cfm b/profiling/dap-target.cfm new file mode 100644 index 0000000..6217038 --- /dev/null +++ b/profiling/dap-target.cfm @@ -0,0 +1,14 @@ + +// Simple target script for DAP testing. +// Set a breakpoint on the line inside testFunc to test debugging. + +function testFunc( required string name ) { + var greeting = "Hello, #arguments.name#!"; // <- set breakpoint here (line 7) + var timestamp = now(); + return greeting; +} + +result = testFunc( "World" ); + +systemOutput( "dap-target.cfm completed: #result#", true ); + diff --git a/profiling/profile-baseline-docs.bat b/profiling/profile-baseline-docs.bat new file mode 100644 index 0000000..fb875b9 --- /dev/null +++ b/profiling/profile-baseline-docs.bat @@ -0,0 +1,34 @@ +@echo off +setlocal + +rem ============================================================ +rem Profile luceedebug - BASELINE using lucee-docs build +rem ============================================================ + +set SCRIPT_RUNNER=D:\work\script-runner +set DOCS_DIR=D:\work\lucee-docs +set OUTPUT_DIR=D:\work\lucee-extensions\luceedebug\profiling\output +set JFC_CONFIG=D:\work\lucee-testlab\jfc-high-frequency.jfc + +rem Extensions from lucee-docs build +set EXTENSIONS=60772C12-F179-D555-8E2CD2B4F7428718;version=4.0.0.0,D46B46A9-A0E3-44E1-D972A04AC3A8DC10;version=2.0.0.1,EFDEB172-F52E-4D84-9CD1A1F561B3DFC8;version=3.0.0.163-RC,FAD67145-E3AE-30F8-1C11A6CCF544F0B7;version=2.0.0.3,6E2CB28F-98FB-4B51-B6BE6C64ADF35473;version=1.0.0.6,DF28D0A4-6748-44B9-A2FDC12E4E2E4D38;version=1.5.0.5,7891D723-8F78-45F5-B7E333A22F8467CA;version=1.0.0.9,261114AC-7372-4CA8-BA7090895E01682D;version=1.0.0.5,A03F4335-BDEF-44DE-946FB16C47802F96;version=1.0.0.0-RC,3F9DFF32-B555-449D-B0EB5DB723044045;version=3.0.0.17 + +rem Create output directory +if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" + +echo. +echo ============================================================ +echo BASELINE PROFILE - lucee-docs build without luceedebug +echo ============================================================ +echo. + +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeVersionQuery="7/all/jar" -Dwebroot="%DOCS_DIR%" -Dexecute="build.cfm" -Dextensions="%EXTENSIONS%" -DFlightRecording="true" -DFlightRecordingFilename="%OUTPUT_DIR%\baseline-docs.jfr" -DFlightRecordingSettings="%JFC_CONFIG%" -DuniqueWorkingDir="true" -DpostCleanup="false" > "%OUTPUT_DIR%\baseline-docs-output.txt" 2>&1 + +echo. +echo Results saved to: %OUTPUT_DIR%\baseline-docs-output.txt +echo JFR saved to: %OUTPUT_DIR%\baseline-docs.jfr +echo. + +type "%OUTPUT_DIR%\baseline-docs-output.txt" | findstr /C:"Total time:" /C:"BUILD" + +endlocal diff --git a/profiling/profile-baseline-spreadsheet.bat b/profiling/profile-baseline-spreadsheet.bat new file mode 100644 index 0000000..1a90451 --- /dev/null +++ b/profiling/profile-baseline-spreadsheet.bat @@ -0,0 +1,31 @@ +@echo off +setlocal + +rem ============================================================ +rem Profile luceedebug - BASELINE using lucee-spreadsheet tests +rem ============================================================ + +set SCRIPT_RUNNER=D:\work\script-runner +set SPREADSHEET_DIR=D:\work\lucee-spreadsheet +set OUTPUT_DIR=D:\work\lucee-extensions\luceedebug\profiling\output +set JFC_CONFIG=D:\work\lucee-testlab\jfc-high-frequency.jfc + +rem Create output directory +if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" + +echo. +echo ============================================================ +echo BASELINE PROFILE - lucee-spreadsheet without luceedebug +echo ============================================================ +echo. + +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeVersionQuery="7/all/jar" -Dwebroot="%SPREADSHEET_DIR%" -Dexecute="/test/index.cfm" -DFlightRecording="true" -DFlightRecordingFilename="%OUTPUT_DIR%\baseline-spreadsheet.jfr" -DFlightRecordingSettings="%JFC_CONFIG%" -DpostCleanup="false" > "%OUTPUT_DIR%\baseline-spreadsheet-output.txt" 2>&1 + +echo. +echo Results saved to: %OUTPUT_DIR%\baseline-spreadsheet-output.txt +echo JFR saved to: %OUTPUT_DIR%\baseline-spreadsheet.jfr +echo. + +type "%OUTPUT_DIR%\baseline-spreadsheet-output.txt" | findstr /C:"Total time:" /C:"BUILD" + +endlocal diff --git a/profiling/profile-baseline.bat b/profiling/profile-baseline.bat new file mode 100644 index 0000000..fe20c46 --- /dev/null +++ b/profiling/profile-baseline.bat @@ -0,0 +1,31 @@ +@echo off +setlocal + +rem ============================================================ +rem Profile luceedebug - BASELINE (no agent) +rem ============================================================ + +set SCRIPT_RUNNER=D:\work\script-runner +set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling +set OUTPUT_DIR=%PROFILING_DIR%\output +set JFC_CONFIG=D:\work\lucee-testlab\jfc-high-frequency.jfc + +rem Create output directory +if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" + +echo. +echo ============================================================ +echo BASELINE PROFILE - Lucee without luceedebug +echo ============================================================ +echo. + +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeVersionQuery="7/all/jar" -Dwebroot="%PROFILING_DIR%" -Dexecute="benchmark.cfm" -DFlightRecording="true" -DFlightRecordingFilename="%OUTPUT_DIR%\baseline.jfr" -DFlightRecordingSettings="%JFC_CONFIG%" -DpostCleanup="false" > "%OUTPUT_DIR%\baseline-output.txt" 2>&1 + +echo. +echo Results saved to: %OUTPUT_DIR%\baseline-output.txt +echo JFR saved to: %OUTPUT_DIR%\baseline.jfr +echo. + +type "%OUTPUT_DIR%\baseline-output.txt" | findstr /C:"Time:" /C:"===" /C:"Benchmark" + +endlocal diff --git a/profiling/profile-local-lucee.bat b/profiling/profile-local-lucee.bat new file mode 100644 index 0000000..2ba9d93 --- /dev/null +++ b/profiling/profile-local-lucee.bat @@ -0,0 +1,52 @@ +@echo off +setlocal + +rem ============================================================ +rem Profile local Lucee7 build - test native debug overhead +rem ============================================================ + +set SCRIPT_RUNNER=D:\work\script-runner +set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling +set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.0.1.97-SNAPSHOT.jar +set OUTPUT_DIR=%PROFILING_DIR%\output + +rem Check if Lucee JAR exists +if not exist "%LUCEE_JAR%" ( + echo ERROR: Lucee JAR not found at %LUCEE_JAR% + echo Run: cd /d/work/lucee7/loader ^&^& ant fast + exit /b 1 +) + +rem Create output directory +if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" + +echo. +echo ============================================================ +echo Profile Local Lucee7 Build - Native Debug Overhead Test +echo ============================================================ +echo. +echo Lucee JAR: %LUCEE_JAR% +echo. + +echo --- Run 1: DEBUGGER_ENABLED=false --- +set LUCEE_DEBUGGER_ENABLED=false +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="benchmark.cfm" -DpostCleanup="false" -DpreCleanup="true" > "%OUTPUT_DIR%\local-debug-off.txt" 2>&1 + +echo. +echo --- Run 2: DEBUGGER_ENABLED=true --- +set LUCEE_DEBUGGER_ENABLED=true +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="benchmark.cfm" -DpostCleanup="false" -DpreCleanup="true" > "%OUTPUT_DIR%\local-debug-on.txt" 2>&1 + +echo. +echo ============================================================ +echo Results +echo ============================================================ +echo. +echo DEBUGGER_ENABLED=false: +type "%OUTPUT_DIR%\local-debug-off.txt" | findstr /C:"Time:" +echo. +echo DEBUGGER_ENABLED=true: +type "%OUTPUT_DIR%\local-debug-on.txt" | findstr /C:"Time:" +echo. + +endlocal diff --git a/profiling/profile-with-agent-docs.bat b/profiling/profile-with-agent-docs.bat new file mode 100644 index 0000000..a9187a7 --- /dev/null +++ b/profiling/profile-with-agent-docs.bat @@ -0,0 +1,46 @@ +@echo off +setlocal + +rem ============================================================ +rem Profile luceedebug - WITH AGENT using lucee-docs build +rem ============================================================ + +set SCRIPT_RUNNER=D:\work\script-runner +set DOCS_DIR=D:\work\lucee-docs +set LUCEEDEBUG_JAR=D:\work\lucee-extensions\luceedebug\luceedebug\build\libs\luceedebug-2.0.15.jar +set OUTPUT_DIR=D:\work\lucee-extensions\luceedebug\profiling\output +set JFC_CONFIG=D:\work\lucee-testlab\jfc-high-frequency.jfc +set JDWP_PORT=9999 +set DEBUG_PORT=10000 + +rem Extensions from lucee-docs build +set EXTENSIONS=60772C12-F179-D555-8E2CD2B4F7428718;version=4.0.0.0,D46B46A9-A0E3-44E1-D972A04AC3A8DC10;version=2.0.0.1,EFDEB172-F52E-4D84-9CD1A1F561B3DFC8;version=3.0.0.163-RC,FAD67145-E3AE-30F8-1C11A6CCF544F0B7;version=2.0.0.3,6E2CB28F-98FB-4B51-B6BE6C64ADF35473;version=1.0.0.6,DF28D0A4-6748-44B9-A2FDC12E4E2E4D38;version=1.5.0.5,7891D723-8F78-45F5-B7E333A22F8467CA;version=1.0.0.9,261114AC-7372-4CA8-BA7090895E01682D;version=1.0.0.5,A03F4335-BDEF-44DE-946FB16C47802F96;version=1.0.0.0-RC,3F9DFF32-B555-449D-B0EB5DB723044045;version=3.0.0.17 + +rem Check if luceedebug JAR exists +if not exist "%LUCEEDEBUG_JAR%" ( + echo ERROR: luceedebug JAR not found at %LUCEEDEBUG_JAR% + echo Run: cd luceedebug ^&^& gradlew shadowJar + exit /b 1 +) + +rem Create output directory +if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" + +echo. +echo ============================================================ +echo AGENT PROFILE - lucee-docs build with luceedebug +echo ============================================================ +echo. +echo luceedebug JAR: %LUCEEDEBUG_JAR% +echo. + +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeVersionQuery="7/all/jar" -Dwebroot="%DOCS_DIR%" -Dexecute="build.cfm" -Dextensions="%EXTENSIONS%" -Djdwp="true" -DjdwpPort="%JDWP_PORT%" -DjavaAgent="%LUCEEDEBUG_JAR%" -DjavaAgentArgs="jdwpHost=localhost,jdwpPort=%JDWP_PORT%,debugHost=0.0.0.0,debugPort=%DEBUG_PORT%,jarPath=%LUCEEDEBUG_JAR%" -DFlightRecording="true" -DFlightRecordingFilename="%OUTPUT_DIR%\with-agent-docs.jfr" -DFlightRecordingSettings="%JFC_CONFIG%" -DuniqueWorkingDir="true" -DpostCleanup="false" > "%OUTPUT_DIR%\with-agent-docs-output.txt" 2>&1 + +echo. +echo Results saved to: %OUTPUT_DIR%\with-agent-docs-output.txt +echo JFR saved to: %OUTPUT_DIR%\with-agent-docs.jfr +echo. + +type "%OUTPUT_DIR%\with-agent-docs-output.txt" | findstr /C:"Total time:" /C:"BUILD" /C:"luceedebug" + +endlocal diff --git a/profiling/profile-with-agent-spreadsheet.bat b/profiling/profile-with-agent-spreadsheet.bat new file mode 100644 index 0000000..f2b918a --- /dev/null +++ b/profiling/profile-with-agent-spreadsheet.bat @@ -0,0 +1,43 @@ +@echo off +setlocal + +rem ============================================================ +rem Profile luceedebug - WITH AGENT using lucee-spreadsheet tests +rem ============================================================ + +set SCRIPT_RUNNER=D:\work\script-runner +set SPREADSHEET_DIR=D:\work\lucee-spreadsheet +set LUCEEDEBUG_JAR=D:\work\lucee-extensions\luceedebug\luceedebug\build\libs\luceedebug-2.0.15.jar +set OUTPUT_DIR=D:\work\lucee-extensions\luceedebug\profiling\output +set JFC_CONFIG=D:\work\lucee-testlab\jfc-high-frequency.jfc +set JDWP_PORT=9999 +set DEBUG_PORT=10000 + +rem Check if luceedebug JAR exists +if not exist "%LUCEEDEBUG_JAR%" ( + echo ERROR: luceedebug JAR not found at %LUCEEDEBUG_JAR% + echo Run: cd luceedebug ^&^& gradlew shadowJar + exit /b 1 +) + +rem Create output directory +if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" + +echo. +echo ============================================================ +echo AGENT PROFILE - lucee-spreadsheet with luceedebug +echo ============================================================ +echo. +echo luceedebug JAR: %LUCEEDEBUG_JAR% +echo. + +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeVersionQuery="7/all/jar" -Dwebroot="%SPREADSHEET_DIR%" -Dexecute="/test/index.cfm" -Djdwp="true" -DjdwpPort="%JDWP_PORT%" -DjavaAgent="%LUCEEDEBUG_JAR%" -DjavaAgentArgs="jdwpHost=localhost,jdwpPort=%JDWP_PORT%,debugHost=0.0.0.0,debugPort=%DEBUG_PORT%,jarPath=%LUCEEDEBUG_JAR%" -DFlightRecording="true" -DFlightRecordingFilename="%OUTPUT_DIR%\with-agent-spreadsheet.jfr" -DFlightRecordingSettings="%JFC_CONFIG%" -DpostCleanup="false" > "%OUTPUT_DIR%\with-agent-spreadsheet-output.txt" 2>&1 + +echo. +echo Results saved to: %OUTPUT_DIR%\with-agent-spreadsheet-output.txt +echo JFR saved to: %OUTPUT_DIR%\with-agent-spreadsheet.jfr +echo. + +type "%OUTPUT_DIR%\with-agent-spreadsheet-output.txt" | findstr /C:"Total time:" /C:"BUILD" /C:"luceedebug" + +endlocal diff --git a/profiling/profile-with-agent.bat b/profiling/profile-with-agent.bat new file mode 100644 index 0000000..abc1873 --- /dev/null +++ b/profiling/profile-with-agent.bat @@ -0,0 +1,43 @@ +@echo off +setlocal + +rem ============================================================ +rem Profile luceedebug - WITH AGENT (no debugger connected) +rem ============================================================ + +set SCRIPT_RUNNER=D:\work\script-runner +set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling +set LUCEEDEBUG_JAR=D:\work\lucee-extensions\luceedebug\luceedebug\build\libs\luceedebug-2.0.15.jar +set OUTPUT_DIR=%PROFILING_DIR%\output +set JFC_CONFIG=D:\work\lucee-testlab\jfc-high-frequency.jfc +set JDWP_PORT=9999 +set DEBUG_PORT=10000 + +rem Check if luceedebug JAR exists +if not exist "%LUCEEDEBUG_JAR%" ( + echo ERROR: luceedebug JAR not found at %LUCEEDEBUG_JAR% + echo Run: cd luceedebug ^&^& gradlew shadowJar + exit /b 1 +) + +rem Create output directory +if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" + +echo. +echo ============================================================ +echo AGENT PROFILE - Lucee with luceedebug (no debugger connected) +echo ============================================================ +echo. +echo luceedebug JAR: %LUCEEDEBUG_JAR% +echo. + +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeVersionQuery="7/all/jar" -Dwebroot="%PROFILING_DIR%" -Dexecute="benchmark.cfm" -Djdwp="true" -DjdwpPort="%JDWP_PORT%" -DjavaAgent="%LUCEEDEBUG_JAR%" -DjavaAgentArgs="jdwpHost=localhost,jdwpPort=%JDWP_PORT%,debugHost=0.0.0.0,debugPort=%DEBUG_PORT%,jarPath=%LUCEEDEBUG_JAR%" -DFlightRecording="true" -DFlightRecordingFilename="%OUTPUT_DIR%\with-agent.jfr" -DFlightRecordingSettings="%JFC_CONFIG%" -DpostCleanup="false" > "%OUTPUT_DIR%\with-agent-output.txt" 2>&1 + +echo. +echo Results saved to: %OUTPUT_DIR%\with-agent-output.txt +echo JFR saved to: %OUTPUT_DIR%\with-agent.jfr +echo. + +type "%OUTPUT_DIR%\with-agent-output.txt" | findstr /C:"Time:" /C:"===" /C:"Benchmark" /C:"luceedebug" + +endlocal diff --git a/profiling/test-breakpoint-bif.bat b/profiling/test-breakpoint-bif.bat new file mode 100644 index 0000000..5ab49d9 --- /dev/null +++ b/profiling/test-breakpoint-bif.bat @@ -0,0 +1,25 @@ +@echo off +setlocal + +set SCRIPT_RUNNER=D:\work\script-runner +set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling +set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.0.1.97-SNAPSHOT.jar + +if not exist "%LUCEE_JAR%" ( + echo ERROR: Lucee JAR not found at %LUCEE_JAR% + exit /b 1 +) + +echo. +echo === Test 1: DEBUGGER_ENABLED=false (should complete immediately) === +echo. +set LUCEE_DEBUGGER_ENABLED=false +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="test-breakpoint-bif.cfm" -DpostCleanup="false" -DpreCleanup="true" 2>&1 | findstr /C:"===" /C:"isDebuggerEnabled" /C:"breakpoint" /C:"Result" + +echo. +echo === Test 2: DEBUGGER_ENABLED=true (will suspend at breakpoint - Ctrl+C to cancel) === +echo. +set LUCEE_DEBUGGER_ENABLED=true +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="test-breakpoint-bif.cfm" -DpostCleanup="false" -DpreCleanup="true" 2>&1 | findstr /C:"===" /C:"isDebuggerEnabled" /C:"breakpoint" /C:"Result" + +endlocal diff --git a/profiling/test-breakpoint-bif.cfm b/profiling/test-breakpoint-bif.cfm new file mode 100644 index 0000000..62eff05 --- /dev/null +++ b/profiling/test-breakpoint-bif.cfm @@ -0,0 +1,56 @@ + +// Test script for breakpoint() BIF + +systemOutput( "=== Testing breakpoint() BIF ===" ); +systemOutput( "" ); + +systemOutput( "isDebuggerEnabled(): #isDebuggerEnabled()#" ); + +if ( !isDebuggerEnabled() ) { + systemOutput( "" ); + systemOutput( "Debugger is DISABLED - breakpoint() will be a no-op." ); + systemOutput( "Run with LUCEE_DEBUGGER_ENABLED=true to test suspension." ); + systemOutput( "" ); +} + +function testFunction( required string name ) { + var localVar = "Hello, #arguments.name#!"; + systemOutput( "Before breakpoint in testFunction( #arguments.name# )" ); + + // Simple breakpoint + breakpoint(); + + systemOutput( "After breakpoint in testFunction( #arguments.name# )" ); + return localVar; +} + +function conditionalBreakpointTest( required numeric value ) { + systemOutput( "Testing conditional breakpoint with value=#arguments.value#" ); + + // Only break when value > 5 + breakpoint( arguments.value > 5, "value > 5 breakpoint" ); + + return arguments.value * 2; +} + +// Test simple breakpoint +systemOutput( "" ); +systemOutput( "--- Test 1: Simple breakpoint ---" ); +result = testFunction( "World" ); +systemOutput( "Result: #result#" ); + +// Test conditional breakpoint (should NOT suspend) +systemOutput( "" ); +systemOutput( "--- Test 2: Conditional breakpoint (value=3, should NOT suspend) ---" ); +result = conditionalBreakpointTest( 3 ); +systemOutput( "Result: #result#" ); + +// Test conditional breakpoint (SHOULD suspend) +systemOutput( "" ); +systemOutput( "--- Test 3: Conditional breakpoint (value=10, SHOULD suspend) ---" ); +result = conditionalBreakpointTest( 10 ); +systemOutput( "Result: #result#" ); + +systemOutput( "" ); +systemOutput( "=== Test Complete ===" ); + diff --git a/profiling/test-dap-breakpoint.cfm b/profiling/test-dap-breakpoint.cfm new file mode 100644 index 0000000..51d9663 --- /dev/null +++ b/profiling/test-dap-breakpoint.cfm @@ -0,0 +1,145 @@ + +/** + * DAP Breakpoint Test + * + * Tests that native breakpoints work via the DAP protocol. + * + * IMPORTANT: This script must run on a DIFFERENT Lucee instance than the debuggee! + * Otherwise, when the breakpoint hits, this test script will also be frozen. + * + * Setup: + * - Instance A (test runner): Runs this script, NO debugger + * - Instance B (debuggee): Runs with luceedebug agent on port 10000, HTTP on 8888 + * + * Usage: + * Configure the variables below, then run this script. + */ + +// ========== Configuration ========== +DAP_HOST = "localhost"; +DAP_PORT = 10000; +DEBUGGEE_HTTP = "http://localhost:8888"; +TARGET_FILE = "/app/profiling/dap-target.cfm"; // Path as seen by debuggee +BREAKPOINT_LINE = 7; // Line inside testFunc +// =================================== + +systemOutput( "=== DAP Breakpoint Test ===" ); +systemOutput( "" ); + +dap = new DapClient( debug = true ); + +try { + // Connect to debugger + systemOutput( "Connecting to DAP server at #DAP_HOST#:#DAP_PORT#..." ); + dap.connect( DAP_HOST, DAP_PORT ); + systemOutput( "Connected!" ); + + // Initialize + systemOutput( "" ); + systemOutput( "Initializing DAP session..." ); + initResponse = dap.initialize(); + systemOutput( "Initialized. Capabilities: #serializeJSON( initResponse.body ?: {} )#" ); + + // Set breakpoint + systemOutput( "" ); + systemOutput( "Setting breakpoint at #TARGET_FILE#:#BREAKPOINT_LINE#..." ); + bpResponse = dap.setBreakpoints( TARGET_FILE, [ BREAKPOINT_LINE ] ); + systemOutput( "Breakpoints: #serializeJSON( bpResponse.body ?: {} )#" ); + + // Configuration done + systemOutput( "" ); + systemOutput( "Sending configurationDone..." ); + dap.configurationDone(); + systemOutput( "Ready!" ); + + // Trigger the target script via HTTP (in a separate thread so we don't block) + systemOutput( "" ); + systemOutput( "Triggering target script via HTTP..." ); + + httpResult = {}; + thread name="httpTrigger" httpResult=httpResult DEBUGGEE_HTTP=DEBUGGEE_HTTP { + try { + http url="#DEBUGGEE_HTTP#/profiling/dap-target.cfm" result="local.r" timeout=30; + httpResult.status = local.r.statusCode; + httpResult.content = local.r.fileContent; + } catch ( any e ) { + httpResult.error = e.message; + } + } + + // Wait for stopped event + systemOutput( "Waiting for breakpoint to hit..." ); + stoppedEvent = dap.waitForEvent( "stopped", 10000 ); + systemOutput( "" ); + systemOutput( "=== BREAKPOINT HIT ===" ); + systemOutput( "Thread ID: #stoppedEvent.body.threadId#" ); + systemOutput( "Reason: #stoppedEvent.body.reason#" ); + + // Get stack trace + systemOutput( "" ); + systemOutput( "Getting stack trace..." ); + stackResponse = dap.stackTrace( stoppedEvent.body.threadId ); + frames = stackResponse.body.stackFrames ?: []; + + systemOutput( "Stack frames (#frames.len()#):" ); + for ( var i = 1; i <= min( frames.len(), 5 ); i++ ) { + var frame = frames[ i ]; + systemOutput( " [#i#] #frame.name# at #frame.source.path ?: 'unknown'#:#frame.line#" ); + } + + // Verify we stopped at the right place + if ( frames.len() > 0 ) { + var topFrame = frames[ 1 ]; + if ( topFrame.line == BREAKPOINT_LINE ) { + systemOutput( "" ); + systemOutput( "SUCCESS: Stopped at expected line #BREAKPOINT_LINE#" ); + } else { + systemOutput( "" ); + systemOutput( "WARNING: Expected line #BREAKPOINT_LINE#, got #topFrame.line#" ); + } + + // Get scopes and variables for top frame + systemOutput( "" ); + systemOutput( "Getting scopes for frame #topFrame.id#..." ); + scopesResponse = dap.scopes( topFrame.id ); + scopes = scopesResponse.body.scopes ?: []; + + for ( var scope in scopes ) { + systemOutput( " Scope: #scope.name# (ref=#scope.variablesReference#)" ); + + if ( scope.variablesReference > 0 && scope.name == "Local" ) { + varsResponse = dap.getVariables( scope.variablesReference ); + vars = varsResponse.body.variables ?: []; + for ( var v in vars ) { + systemOutput( " #v.name# = #v.value#" ); + } + } + } + } + + // Continue execution + systemOutput( "" ); + systemOutput( "Continuing execution..." ); + dap.continueThread( stoppedEvent.body.threadId ); + + // Wait for HTTP request to complete + threadJoin( "httpTrigger", 5000 ); + + systemOutput( "" ); + if ( httpResult.keyExists( "error" ) ) { + systemOutput( "HTTP request error: #httpResult.error#" ); + } else { + systemOutput( "HTTP request completed: #httpResult.status ?: 'unknown'#" ); + } + + systemOutput( "" ); + systemOutput( "=== TEST COMPLETE ===" ); + +} catch ( any e ) { + systemOutput( "" ); + systemOutput( "ERROR: #e.message#" ); + systemOutput( e.stackTrace ); +} finally { + dap.disconnect(); +} + diff --git a/profiling/test-dap-stepping.bat b/profiling/test-dap-stepping.bat new file mode 100644 index 0000000..4e4f62c --- /dev/null +++ b/profiling/test-dap-stepping.bat @@ -0,0 +1,47 @@ +@echo off +setlocal EnableDelayedExpansion + +rem ============================================================ +rem DAP Stepping Test +rem +rem This test requires TWO Lucee instances: +rem 1. Debuggee: Runs with luceedebug agent, serves HTTP on 8888 +rem 2. Test runner: Runs this test script, connects to DAP on 10000 +rem +rem Since script-runner runs headless, we need an alternative. +rem This script starts CommandBox for the debuggee. +rem ============================================================ + +set SCRIPT_RUNNER=D:\work\script-runner +set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling +set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.0.1.97-SNAPSHOT.jar +set LUCEEDEBUG_JAR=D:\work\lucee-extensions\luceedebug\luceedebug\build\libs\luceedebug-2.0.15.jar +set JDWP_PORT=9999 +set DEBUG_PORT=10000 + +echo. +echo ============================================================ +echo DAP Stepping Test +echo ============================================================ +echo. +echo This test requires TWO Lucee instances running simultaneously. +echo. +echo Option 1: Manual Setup +echo 1. Start debuggee with HTTP server (CommandBox or Tomcat) +echo - Lucee with luceedebug agent on DAP port 10000 +echo - HTTP on port 8888 +echo - Webroot: %PROFILING_DIR% +echo 2. Run this script to execute the test +echo. +echo Option 2: Use existing test infrastructure +echo Run test-native-frames-with-agent.bat to verify agent works +echo Then manually test stepping with VS Code +echo. +echo ============================================================ +echo. + +rem For now, just run the basic native frames test +echo Running native frames test first... +call "%PROFILING_DIR%\test-native-frames-with-agent.bat" + +endlocal diff --git a/profiling/test-dap-stepping.cfm b/profiling/test-dap-stepping.cfm new file mode 100644 index 0000000..ad94c32 --- /dev/null +++ b/profiling/test-dap-stepping.cfm @@ -0,0 +1,238 @@ + +/** + * DAP Stepping Test + * + * Tests stepIn, stepOver, stepOut functionality via the DAP protocol. + * + * IMPORTANT: This script must run on a DIFFERENT Lucee instance than the debuggee! + * Otherwise, when the breakpoint hits, this test script will also be frozen. + * + * Setup: + * - Instance A (test runner): Runs this script, NO debugger + * - Instance B (debuggee): Runs with luceedebug agent on port 10000, HTTP on 8888 + * + * Usage: + * Configure the variables below, then run this script. + */ + +// ========== Configuration ========== +DAP_HOST = "localhost"; +DAP_PORT = 10000; +DEBUGGEE_HTTP = "http://localhost:8888"; +TARGET_FILE = "/app/profiling/dap-step-target.cfm"; // Path as seen by debuggee +BREAKPOINT_LINE = 20; // Line with outerFunc call +// =================================== + +systemOutput( "=== DAP Stepping Test ===", true ); +systemOutput( "", true ); + +// Track test results +testResults = { passed: 0, failed: 0, errors: [] }; + +function assert( required boolean condition, required string message ) { + if ( arguments.condition ) { + testResults.passed++; + systemOutput( " PASS: #arguments.message#", true ); + } else { + testResults.failed++; + testResults.errors.append( arguments.message ); + systemOutput( " FAIL: #arguments.message#", true ); + } +} + +function assertLine( required numeric expected, required numeric actual, required string context ) { + assert( arguments.actual == arguments.expected, "#arguments.context#: expected line #arguments.expected#, got #arguments.actual#" ); +} + +dap = new DapClient( debug = true ); + +try { + // Connect to debugger + systemOutput( "Connecting to DAP server at #DAP_HOST#:#DAP_PORT#...", true ); + dap.connect( DAP_HOST, DAP_PORT ); + systemOutput( "Connected!", true ); + + // Initialize + systemOutput( "", true ); + systemOutput( "Initializing DAP session...", true ); + initResponse = dap.initialize(); + systemOutput( "Initialized.", true ); + + // Set breakpoint + systemOutput( "", true ); + systemOutput( "Setting breakpoint at #TARGET_FILE#:#BREAKPOINT_LINE#...", true ); + bpResponse = dap.setBreakpoints( TARGET_FILE, [ BREAKPOINT_LINE ] ); + systemOutput( "Breakpoints set.", true ); + + // Configuration done + dap.configurationDone(); + systemOutput( "Ready!", true ); + + // Trigger the target script via HTTP (in a separate thread so we don't block) + systemOutput( "", true ); + systemOutput( "Triggering target script via HTTP...", true ); + + httpResult = {}; + thread name="httpTrigger" httpResult=httpResult DEBUGGEE_HTTP=DEBUGGEE_HTTP { + try { + http url="#DEBUGGEE_HTTP#/profiling/dap-step-target.cfm" result="local.r" timeout=60; + httpResult.status = local.r.statusCode; + httpResult.content = local.r.fileContent; + } catch ( any e ) { + httpResult.error = e.message; + } + } + + // ========== Test 1: Initial breakpoint hit ========== + systemOutput( "", true ); + systemOutput( "=== Test 1: Initial Breakpoint ===", true ); + + stoppedEvent = dap.waitForEvent( "stopped", 15000 ); + threadId = stoppedEvent.body.threadId; + systemOutput( "Thread ID: #threadId#", true ); + + stackResponse = dap.stackTrace( threadId ); + frames = stackResponse.body.stackFrames ?: []; + + if ( frames.len() > 0 ) { + assertLine( BREAKPOINT_LINE, frames[ 1 ].line, "Initial breakpoint" ); + } else { + testResults.failed++; + testResults.errors.append( "No stack frames at initial breakpoint" ); + } + + // ========== Test 2: Step Over ========== + systemOutput( "", true ); + systemOutput( "=== Test 2: Step Over (should stay at line 21) ===", true ); + + dap.stepOver( threadId ); + stoppedEvent = dap.waitForEvent( "stopped", 5000 ); + + stackResponse = dap.stackTrace( threadId ); + frames = stackResponse.body.stackFrames ?: []; + + if ( frames.len() > 0 ) { + assertLine( 21, frames[ 1 ].line, "Step over from line 20" ); + } + + // ========== Reset: Continue and hit breakpoint again ========== + systemOutput( "", true ); + systemOutput( "=== Resetting: Continue to end, re-trigger ===", true ); + + dap.continueThread( threadId ); + + // Wait for HTTP to finish + threadJoin( "httpTrigger", 5000 ); + systemOutput( "First run completed.", true ); + + // Re-trigger for stepIn test + httpResult = {}; + thread name="httpTrigger2" httpResult=httpResult DEBUGGEE_HTTP=DEBUGGEE_HTTP { + try { + http url="#DEBUGGEE_HTTP#/profiling/dap-step-target.cfm" result="local.r" timeout=60; + httpResult.status = local.r.statusCode; + httpResult.content = local.r.fileContent; + } catch ( any e ) { + httpResult.error = e.message; + } + } + + stoppedEvent = dap.waitForEvent( "stopped", 15000 ); + threadId = stoppedEvent.body.threadId; + + // ========== Test 3: Step Over (execute outerFunc, land on line 21) ========== + systemOutput( "", true ); + systemOutput( "=== Test 3: Step Over outerFunc call ===", true ); + + dap.stepOver( threadId ); + stoppedEvent = dap.waitForEvent( "stopped", 5000 ); + + stackResponse = dap.stackTrace( threadId ); + frames = stackResponse.body.stackFrames ?: []; + + if ( frames.len() > 0 ) { + assertLine( 21, frames[ 1 ].line, "Step over outerFunc" ); + } + + // ========== Test 4: Continue to end, re-trigger for stepIn ========== + systemOutput( "", true ); + systemOutput( "=== Test 4: Step In test ===", true ); + + dap.continueThread( threadId ); + threadJoin( "httpTrigger2", 5000 ); + + // Re-trigger for stepIn test + httpResult = {}; + thread name="httpTrigger3" httpResult=httpResult DEBUGGEE_HTTP=DEBUGGEE_HTTP { + try { + http url="#DEBUGGEE_HTTP#/profiling/dap-step-target.cfm" result="local.r" timeout=60; + httpResult.status = local.r.statusCode; + httpResult.content = local.r.fileContent; + } catch ( any e ) { + httpResult.error = e.message; + } + } + + stoppedEvent = dap.waitForEvent( "stopped", 15000 ); + threadId = stoppedEvent.body.threadId; + + // Step into outerFunc + dap.stepIn( threadId ); + stoppedEvent = dap.waitForEvent( "stopped", 5000 ); + + stackResponse = dap.stackTrace( threadId ); + frames = stackResponse.body.stackFrames ?: []; + + if ( frames.len() > 0 ) { + // Should be inside outerFunc, line 12 + assertLine( 12, frames[ 1 ].line, "Step into outerFunc" ); + } + + // ========== Test 5: Step Out ========== + systemOutput( "", true ); + systemOutput( "=== Test 5: Step Out ===", true ); + + dap.stepOut( threadId ); + stoppedEvent = dap.waitForEvent( "stopped", 5000 ); + + stackResponse = dap.stackTrace( threadId ); + frames = stackResponse.body.stackFrames ?: []; + + if ( frames.len() > 0 ) { + // Should be back at line 21 (after outerFunc call) + assertLine( 21, frames[ 1 ].line, "Step out of outerFunc" ); + } + + // Continue to complete + dap.continueThread( threadId ); + threadJoin( "httpTrigger3", 5000 ); + + // ========== Summary ========== + systemOutput( "", true ); + systemOutput( "========================================", true ); + systemOutput( "TEST SUMMARY", true ); + systemOutput( "========================================", true ); + systemOutput( "Passed: #testResults.passed#", true ); + systemOutput( "Failed: #testResults.failed#", true ); + + if ( testResults.errors.len() > 0 ) { + systemOutput( "", true ); + systemOutput( "Failures:", true ); + for ( var err in testResults.errors ) { + systemOutput( " - #err#", true ); + } + } + + if ( testResults.failed == 0 ) { + systemOutput( "", true ); + systemOutput( "ALL TESTS PASSED!", true ); + } + +} catch ( any e ) { + systemOutput( "", true ); + systemOutput( "ERROR: #e.message#", true ); + systemOutput( e.stackTrace, true ); +} finally { + dap.disconnect(); +} + diff --git a/profiling/test-debugger-frames.cfm b/profiling/test-debugger-frames.cfm new file mode 100644 index 0000000..3af3b06 --- /dev/null +++ b/profiling/test-debugger-frames.cfm @@ -0,0 +1,68 @@ + +// Test script for Lucee's new debugger frame support +// Run with: -Dlucee.debugger.enabled=true + +systemOutput( "=== Testing Debugger Frame Support ===" ); +systemOutput( "" ); + +// Check if debugger is enabled +pc = getPageContext(); +pci = pc.getClass().getName() == "lucee.runtime.PageContextImpl" ? pc : javaCast( "null", "" ); + +if ( isNull( pci ) ) { + systemOutput( "ERROR: Could not get PageContextImpl" ); + abort; +} + +// Check the static flag +debuggerEnabled = createObject( "java", "lucee.runtime.PageContextImpl" ).DEBUGGER_ENABLED; +systemOutput( "DEBUGGER_ENABLED: #debuggerEnabled#" ); + +if ( !debuggerEnabled ) { + systemOutput( "" ); + systemOutput( "Debugger is DISABLED. Run with -Dlucee.debugger.enabled=true" ); + systemOutput( "" ); + abort; +} + +// Test nested function calls +function level3( required string msg ) { + var localVar = "I am in level3"; + var frames = getPageContext().getDebuggerFrames(); + + systemOutput( "" ); + systemOutput( "Inside level3() - #arguments.msg#" ); + systemOutput( " localVar: #localVar#" ); + systemOutput( " Frame count: #arrayLen( frames )#" ); + + for ( var i = 1; i <= arrayLen( frames ); i++ ) { + var frame = frames[ i ]; + systemOutput( " Frame #i#: #frame.functionName# @ #frame.pageSource.getDisplayPath()#" ); + systemOutput( " - local keys: #structKeyList( frame.local )#" ); + systemOutput( " - arguments keys: #structKeyList( frame.arguments )#" ); + } + + return localVar; +} + +function level2( required numeric num ) { + var l2Var = "level2 local"; + return level3( "called from level2 with num=#arguments.num#" ); +} + +function level1() { + var l1Var = "level1 local"; + var result = level2( 42 ); + return result; +} + +// Run the test +systemOutput( "" ); +systemOutput( "Calling level1() -> level2() -> level3()..." ); +result = level1(); + +systemOutput( "" ); +systemOutput( "Result: #result#" ); +systemOutput( "" ); +systemOutput( "=== Test Complete ===" ); + diff --git a/profiling/test-extension.bat b/profiling/test-extension.bat new file mode 100644 index 0000000..84672bf --- /dev/null +++ b/profiling/test-extension.bat @@ -0,0 +1,45 @@ +@echo off +setlocal + +rem ============================================================ +rem Test luceedebug extension deployment with local Lucee7 build +rem ============================================================ + +set SCRIPT_RUNNER=D:\work\script-runner +set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling +set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.1.0.7-ALPHA.jar +set EXTENSION_DIR=D:\work\lucee-extensions\luceedebug\luceedebug\build\extension + +rem Check if Lucee JAR exists +if not exist "%LUCEE_JAR%" ( + echo ERROR: Lucee JAR not found at %LUCEE_JAR% + echo Run: cd /d/work/lucee7/loader ^&^& ant fast + exit /b 1 +) + +rem Check if extension exists +if not exist "%EXTENSION_DIR%\luceedebug-extension-3.0.0.lex" ( + echo ERROR: Extension not found at %EXTENSION_DIR% + echo Run: cd /d/work/lucee-extensions/luceedebug ^&^& gradlew buildExtension + exit /b 1 +) + +echo. +echo ============================================================ +echo Testing Luceedebug Extension Deployment +echo ============================================================ +echo. +echo Lucee JAR: %LUCEE_JAR% +echo Extension: %EXTENSION_DIR% +echo. + +rem Enable debugger via env var - set port to enable +set LUCEE_DEBUGGER_PORT=10000 + +rem Enable verbose logging +set LUCEE_LOGGING_FORCE_APPENDER=console +set LUCEE_LOGGING_FORCE_LEVEL=debug + +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -DextensionDir="%EXTENSION_DIR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="test-extension.cfm" -DpostCleanup="false" -DpreCleanup="true" + +endlocal diff --git a/profiling/test-extension.cfm b/profiling/test-extension.cfm new file mode 100644 index 0000000..5bbe5b3 --- /dev/null +++ b/profiling/test-extension.cfm @@ -0,0 +1,78 @@ + +// Test script for luceedebug extension deployment +// Run with: profiling\test-extension.bat + +function runTest() { + systemOutput( "============================================================", true ); + systemOutput( "Testing Luceedebug Extension Deployment", true ); + systemOutput( "============================================================", true ); + systemOutput( "", true ); + + // Check environment + systemOutput( "Environment:", true ); + systemOutput( " LUCEE_DEBUGGER_PORT: " & ( server.system.environment.LUCEE_DEBUGGER_PORT ?: "not set" ), true ); + systemOutput( " Lucee Version: " & server.lucee.version, true ); + systemOutput( "", true ); + + // Check if extension is installed via admin API + systemOutput( "Checking installed extensions...", true ); + try { + var admin = new Administrator( "server", "password" ); + var extensions = admin.getExtensions(); + var found = false; + for ( var ext in extensions ) { + if ( ext.id contains "DECEB" || ext.name contains "luceedebug" || ext.name contains "Luceedebug" ) { + systemOutput( " FOUND: #ext.name# v#ext.version# (id: #ext.id#)", true ); + found = true; + } + } + if ( !found ) { + systemOutput( " WARNING: luceedebug extension not found in installed list", true ); + systemOutput( " Available extensions:", true ); + for ( var ext in extensions ) { + systemOutput( " - #ext.name# (#ext.id#)", true ); + } + } + } catch ( any e ) { + systemOutput( " Could not check extensions via admin: " & e.message, true ); + } + systemOutput( "", true ); + + // Check if DebuggerRegistry exists (Lucee 7.1+) + systemOutput( "Checking DebuggerRegistry (Lucee 7.1+ native debugging API)...", true ); + try { + var DebuggerRegistry = createObject( "java", "lucee.runtime.debug.DebuggerRegistry" ); + systemOutput( " DebuggerRegistry class loaded", true ); + + // Try to get the listener + try { + var listener = DebuggerRegistry.getListener(); + if ( !isNull( listener ) ) { + systemOutput( " Listener registered: " & listener.getClass().getName(), true ); + } else { + systemOutput( " No listener registered (null)", true ); + } + } catch ( any e ) { + systemOutput( " Could not get listener: " & e.message, true ); + } + } catch ( any e ) { + systemOutput( " ERROR: DebuggerRegistry not found - requires Lucee 7.1+", true ); + systemOutput( " " & e.message, true ); + } + systemOutput( "", true ); + + // Simple variable test + systemOutput( "Testing basic execution...", true ); + var x = 1; + var y = 2; + var z = x + y; + systemOutput( " x + y = #z# (execution test passed)", true ); + systemOutput( "", true ); + + systemOutput( "============================================================", true ); + systemOutput( "Extension test complete", true ); + systemOutput( "============================================================", true ); +} + +runTest(); + diff --git a/profiling/test-native-frames-with-agent.bat b/profiling/test-native-frames-with-agent.bat new file mode 100644 index 0000000..95db53b --- /dev/null +++ b/profiling/test-native-frames-with-agent.bat @@ -0,0 +1,50 @@ +@echo off +setlocal + +rem ============================================================ +rem Test native debugger frames with luceedebug agent +rem ============================================================ + +set SCRIPT_RUNNER=D:\work\script-runner +set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling +set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.1.0.7-ALPHA.jar +set LUCEEDEBUG_JAR=D:\work\lucee-extensions\luceedebug\luceedebug\build\libs\luceedebug-2.0.15.jar +set JDWP_PORT=9999 +set DEBUG_PORT=10000 + +rem Check if Lucee JAR exists +if not exist "%LUCEE_JAR%" ( + echo ERROR: Lucee JAR not found at %LUCEE_JAR% + echo Run: cd /d/work/lucee7/loader ^&^& ant fast + exit /b 1 +) + +rem Check if luceedebug JAR exists +if not exist "%LUCEEDEBUG_JAR%" ( + echo ERROR: luceedebug JAR not found at %LUCEEDEBUG_JAR% + echo Run: cd luceedebug ^&^& gradlew shadowJar + exit /b 1 +) + +echo. +echo ============================================================ +echo Testing Native Debugger Frames with luceedebug agent +echo ============================================================ +echo. +echo Lucee JAR: %LUCEE_JAR% +echo luceedebug JAR: %LUCEEDEBUG_JAR% +echo. +echo DAP server will listen on port %DEBUG_PORT% +echo Connect VS Code debugger to test breakpoints +echo. + +rem Enable debugger via env var +set LUCEE_DEBUGGER_ENABLED=true + +rem Enable trace logging to console +set lucee_logging_force_level=trace +set lucee_logging_force_appender=console + +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="test-debugger-frames.cfm" -Djdwp="true" -DjdwpPort="%JDWP_PORT%" -DjavaAgent="%LUCEEDEBUG_JAR%" -DjavaAgentArgs="jdwpHost=localhost,jdwpPort=%JDWP_PORT%,debugHost=0.0.0.0,debugPort=%DEBUG_PORT%,jarPath=%LUCEEDEBUG_JAR%" -DpostCleanup="false" -DpreCleanup="true" + +endlocal diff --git a/profiling/test-native-frames.bat b/profiling/test-native-frames.bat new file mode 100644 index 0000000..32cfc08 --- /dev/null +++ b/profiling/test-native-frames.bat @@ -0,0 +1,32 @@ +@echo off +setlocal + +rem ============================================================ +rem Test native debugger frames with local Lucee7 build +rem ============================================================ + +set SCRIPT_RUNNER=D:\work\script-runner +set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling +set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.0.1.97-SNAPSHOT.jar + +rem Check if Lucee JAR exists +if not exist "%LUCEE_JAR%" ( + echo ERROR: Lucee JAR not found at %LUCEE_JAR% + echo Run: cd /d/work/lucee7/loader ^&^& ant fast + exit /b 1 +) + +echo. +echo ============================================================ +echo Testing Native Debugger Frames with local Lucee7 build +echo ============================================================ +echo. +echo Lucee JAR: %LUCEE_JAR% +echo. + +rem Enable debugger via env var +set LUCEE_DEBUGGER_ENABLED=true + +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="test-debugger-frames.cfm" -DpostCleanup="false" -DpreCleanup="true" + +endlocal From 4b699b2e82db02b0c3808b6d59f90b9993b884bb Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 4 Dec 2025 17:44:06 +0100 Subject: [PATCH 02/14] native debugger, fixes and fine tuning --- .../src/main/java/luceedebug/DapServer.java | 180 ++++++++++++-- .../src/main/java/luceedebug/EnvUtil.java | 113 +++++++++ luceedebug/src/main/java/luceedebug/Log.java | 80 ++++++ .../coreinject/CfValueDebuggerBridge.java | 96 +++++-- .../luceedebug/coreinject/DebugManager.java | 9 +- .../coreinject/NativeDebuggerListener.java | 179 ++++++++++++-- .../luceedebug/coreinject/NativeLuceeVm.java | 149 ++++++++--- .../luceedebug/coreinject/ValTracker.java | 3 +- .../coreinject/frame/NativeDebugFrame.java | 234 +++++++++++++++--- .../extension/ExtensionActivator.java | 149 ++++++++--- profiling/DapClient.cfc | 19 +- profiling/test-extension.bat | 1 + profiling/test-threads.bat | 30 +++ profiling/test-threads.cfm | 40 +++ 14 files changed, 1106 insertions(+), 176 deletions(-) create mode 100644 luceedebug/src/main/java/luceedebug/EnvUtil.java create mode 100644 luceedebug/src/main/java/luceedebug/Log.java create mode 100644 profiling/test-threads.bat create mode 100644 profiling/test-threads.cfm diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index bb5d201..d162004 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -24,6 +24,7 @@ import org.eclipse.lsp4j.jsonrpc.services.JsonRequest; import org.eclipse.lsp4j.jsonrpc.util.ToStringBuilder; +import luceedebug.coreinject.NativeDebuggerListener; import luceedebug.strong.CanonicalServerAbsPath; import luceedebug.strong.RawIdePath; @@ -32,10 +33,16 @@ public class DapServer implements IDebugProtocolServer { private final Config config_; private ArrayList pathTransforms = new ArrayList<>(); - // for dev, system.out was fine, in some containers, others totally suppress it and it doesn't even + // for dev, system.out was fine, in some containers, others totally suppress it and it doesn't even // end up in log files. // this is all jacked up, on runwar builds it spits out two lines per call to `logger.info(...)` message, the first one being [ERROR] which is not right private static final Logger logger = Logger.getLogger("luceedebug"); + + // Static reference for shutdown within same classloader + private static volatile ServerSocket activeServerSocket; + + // System property key for tracking the server socket across classloaders (OSGi bundle reload) + private static final String SOCKET_PROPERTY = "luceedebug.dap.serverSocket"; private String applyPathTransformsIdeToCf(String s) { for (var transform : pathTransforms) { @@ -126,7 +133,7 @@ private DapServer(ILuceeVm luceeVm, Config config) { event.setDescription(label); } clientProxy_.stopped(event); - System.out.println("[luceedebug] Sent DAP stopped event for native breakpoint, thread=" + javaThreadId + (label != null ? " label=" + label : "")); + Log.info("Sent DAP stopped event for native breakpoint, thread=" + javaThreadId + (label != null ? " label=" + label : "")); }); } @@ -140,13 +147,39 @@ private DapEntry(DapServer server, Launcher launcher) { } static public DapEntry createForSocket(ILuceeVm luceeVm, Config config, String host, int port) { - try (var server = new ServerSocket()) { + // Shut down any existing server first (handles extension reinstall within same classloader) + shutdown(); + + ServerSocket server = null; + try { + server = new ServerSocket(); var addr = new InetSocketAddress(host, port); server.setReuseAddress(true); logger.finest("binding cf dap server socket on " + host + ":" + port); - server.bind(addr); + // Try to bind, with retries for port in use (OSGi bundle reload race condition) + int maxRetries = 5; + java.net.BindException lastBindError = null; + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + server.bind(addr); + lastBindError = null; + break; + } catch (java.net.BindException e) { + lastBindError = e; + if (attempt < maxRetries) { + Log.info("Port " + port + " in use, retrying in 1s (attempt " + attempt + "/" + maxRetries + ")"); + try { java.lang.Thread.sleep(1000); } catch (InterruptedException ie) { break; } + } + } + } + if (lastBindError != null) { + throw lastBindError; + } + activeServerSocket = server; + // Store in system properties so it survives classloader changes (OSGi bundle reload) + System.getProperties().put(SOCKET_PROPERTY, server); logger.finest("dap server socket bind OK"); @@ -154,21 +187,110 @@ static public DapEntry createForSocket(ILuceeVm luceeVm, Config config, String h logger.finest("listening for inbound debugger connection on " + host + ":" + port + "..."); var socket = server.accept(); - - logger.finest("accepted debugger connection"); - - var dapEntry = create(luceeVm, config, socket.getInputStream(), socket.getOutputStream()); - var future = dapEntry.launcher.startListening(); - future.get(); // block until the connection closes + var clientAddr = socket.getInetAddress().getHostAddress(); + var clientPort = socket.getPort(); + + Log.info("DAP client connected from " + clientAddr + ":" + clientPort); + logger.finest("accepted debugger connection from " + clientAddr + ":" + clientPort); + + // Mark DAP client as connected - enables breakpoint() BIF to suspend + luceedebug.coreinject.NativeDebuggerListener.setDapClientConnected(true); + + try { + Log.info("Creating DAP entry..."); + // Wrap streams with logging + var rawIn = socket.getInputStream(); + var rawOut = socket.getOutputStream(); + var loggingIn = new java.io.FilterInputStream(rawIn) { + @Override + public int read() throws java.io.IOException { + int b = super.read(); + if (b >= 0) { + System.out.print((char)b); + } + return b; + } + @Override + public int read(byte[] b, int off, int len) throws java.io.IOException { + int n = super.read(b, off, len); + if (n > 0) { + System.out.print("[IN:" + n + "]" + new String(b, off, n, "UTF-8")); + } + return n; + } + }; + var dapEntry = create(luceeVm, config, loggingIn, rawOut); + Log.info("DAP launcher created, starting listening..."); + // Enable DAP output for this client + Log.setDapClient(dapEntry.server.clientProxy_); + var future = dapEntry.launcher.startListening(); + Log.info("DAP launcher started, waiting for connection to close..."); + try { + future.get(); // block until the connection closes + } catch (Exception e) { + Log.error("Launcher future exception", e); + } + Log.info("DAP client disconnected from " + clientAddr + ":" + clientPort); + } catch (Exception e) { + Log.error("DAP client error: " + e.getClass().getName(), e); + } finally { + // Mark DAP client as disconnected - disables breakpoint() BIF suspension + luceedebug.coreinject.NativeDebuggerListener.setDapClientConnected(false); + Log.setDapClient(null); // Disable DAP output + try { socket.close(); } catch (Exception ignored) {} + Log.info("DAP client socket closed for " + clientAddr + ":" + clientPort); + } logger.finest("debugger connection closed"); } } + catch (java.net.SocketException e) { + // Expected when shutdown() closes the socket + Log.info("DAP server socket closed"); + return null; + } catch (Throwable e) { e.printStackTrace(); System.exit(1); return null; } + finally { + // Only clear if we're still the active server (avoid race with new server starting) + if (activeServerSocket == server) { + activeServerSocket = null; + System.getProperties().remove(SOCKET_PROPERTY); + } + } + } + + /** + * Shutdown the DAP server. Called on extension uninstall/reinstall. + * Uses System.getProperties() to find socket from previous classloaders. + */ + public static void shutdown() { + // Try to get socket from JVM-wide properties (survives classloader changes) + Object storedSocket = System.getProperties().get(SOCKET_PROPERTY); + if (storedSocket instanceof ServerSocket) { + ServerSocket socket = (ServerSocket) storedSocket; + Log.info("shutdown() - found socket in system properties, closing..."); + try { + socket.close(); + Log.info("shutdown() - socket closed"); + } catch (Exception e) { + Log.error("shutdown() - socket close error", e); + } + System.getProperties().remove(SOCKET_PROPERTY); + } else { + Log.info("shutdown() - no socket in system properties"); + } + + // Also close our local static reference if set + if (activeServerSocket != null) { + try { + activeServerSocket.close(); + } catch (Exception ignored) {} + activeServerSocket = null; + } } static public DapEntry create(ILuceeVm luceeVm, Config config, InputStream in, OutputStream out) { @@ -180,6 +302,8 @@ static public DapEntry create(ILuceeVm luceeVm, Config config, InputStream in, O @Override public CompletableFuture initialize(InitializeRequestArguments args) { + Log.info("initialize() called with args: " + args); + System.out.println("[luceedebug] INITIALIZE CALLED - this should appear in Tomcat logs"); var c = new Capabilities(); c.setSupportsEvaluateForHovers(true); c.setSupportsConfigurationDoneRequest(true); @@ -189,6 +313,14 @@ public CompletableFuture initialize(InitializeRequestArguments arg c.setSupportsHitConditionalBreakpoints(false); // still shows UI for it though c.setSupportsLogPoints(false); // still shows UI for it though + // Exception breakpoint filters + var uncaughtFilter = new ExceptionBreakpointsFilter(); + uncaughtFilter.setFilter("uncaught"); + uncaughtFilter.setLabel("Uncaught Exceptions"); + uncaughtFilter.setDescription("Break when an exception is not caught by a try/catch block"); + c.setExceptionBreakpointFilters(new ExceptionBreakpointsFilter[] { uncaughtFilter }); + Log.info("Returning capabilities with exceptionBreakpointFilters: " + java.util.Arrays.toString(c.getExceptionBreakpointFilters())); + return CompletableFuture.completedFuture(c); } @@ -259,12 +391,12 @@ public CompletableFuture attach(Map args) { clientProxy_.initialized(); if (pathTransforms.size() == 0) { - logger.finest("attached to frontend, using path transforms "); + Log.info("DAP client attached - no path transforms configured"); } else { - logger.finest("attached to frontend, using path transforms:"); + Log.info("DAP client attached - path transforms:"); for (var transform : pathTransforms) { - logger.finest(transform.asTraceString()); + Log.info(" " + transform.asTraceString()); } } @@ -321,8 +453,11 @@ public CompletableFuture stackTrace(StackTraceArguments args for (var cfFrame : luceeVm_.getStackTrace(args.getThreadId())) { final var source = new Source(); - source.setPath(applyPathTransformsServerToIde(cfFrame.getSourceFilePath())); - + String rawPath = cfFrame.getSourceFilePath(); + String transformedPath = applyPathTransformsServerToIde(rawPath); + Log.info("stackTrace: raw=" + rawPath + " -> transformed=" + transformedPath); + source.setPath(transformedPath); + final var lspFrame = new org.eclipse.lsp4j.debug.StackFrame(); lspFrame.setId((int)cfFrame.getId()); lspFrame.setName(cfFrame.getName()); @@ -429,7 +564,20 @@ private Breakpoint map_cfBreakpoint_to_lsp4jBreakpoint(IBreakpoint cfBreakpoint) */ @Override public CompletableFuture setExceptionBreakpoints(SetExceptionBreakpointsArguments args) { - // set success false? + // Check if "uncaught" is in the filters + String[] filters = args.getFilters(); + Log.info("setExceptionBreakpoints: filters=" + java.util.Arrays.toString(filters)); + boolean breakOnUncaught = false; + if (filters != null) { + for (String filter : filters) { + if ("uncaught".equals(filter)) { + breakOnUncaught = true; + break; + } + } + } + NativeDebuggerListener.setBreakOnUncaughtExceptions(breakOnUncaught); + Log.info("setExceptionBreakpoints: uncaught=" + breakOnUncaught); return CompletableFuture.completedFuture(new SetExceptionBreakpointsResponse()); } diff --git a/luceedebug/src/main/java/luceedebug/EnvUtil.java b/luceedebug/src/main/java/luceedebug/EnvUtil.java new file mode 100644 index 0000000..ec900af --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/EnvUtil.java @@ -0,0 +1,113 @@ +package luceedebug; + +/** + * Utility class for reading environment variables and system properties + * in Lucee's naming convention. + */ +public final class EnvUtil { + + private EnvUtil() {} + + /** + * Get system property or environment variable. + * System property takes precedence. Env var name is derived from property name + * by uppercasing and replacing dots with underscores. + * + * @param propertyName e.g. "lucee.debugger.enabled" + * @return the value, or null if not set + */ + public static String getSystemPropOrEnvVar(String propertyName) { + // Try system property first + String value = System.getProperty(propertyName); + if (value != null && !value.isEmpty()) { + return value; + } + // Try env var (lucee.debugger.port -> LUCEE_DEBUGGER_PORT) + String envName = propertyName.toUpperCase().replace('.', '_'); + return System.getenv(envName); + } + + /** + * Check if debugger is enabled via environment variable or system property. + * Checks "lucee.debugger.enabled" / "LUCEE_DEBUGGER_ENABLED". + * + * @return true if debugger is enabled + */ + public static boolean isDebuggerEnabled() { + String value = getSystemPropOrEnvVar("lucee.debugger.enabled"); + return "true".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value) || "1".equals(value); + } + + /** + * Get debugger port from environment/system property. + * Checks "lucee.debugger.port" / "LUCEE_DEBUGGER_PORT". + * + * @return the port number, or -1 if not set (debugger disabled) + */ + public static int getDebuggerPort() { + String port = getSystemPropOrEnvVar("lucee.debugger.port"); + if (port == null || port.isEmpty()) { + return -1; + } + try { + return Integer.parseInt(port); + } catch (NumberFormatException e) { + return -1; + } + } + + /** + * Log level for debugger output. + */ + public enum LogLevel { + ERROR(0), + INFO(1), + DEBUG(2); + + private final int level; + + LogLevel(int level) { + this.level = level; + } + + public boolean isEnabled(LogLevel threshold) { + return this.level <= threshold.level; + } + } + + // Cached log level - read once from env + private static LogLevel cachedLogLevel = null; + + /** + * Get log level from environment/system property. + * Checks "lucee.debugger.loglevel" / "LUCEE_DEBUGGER_LOGLEVEL". + * Valid values: error, info, debug (case-insensitive) + * Default: info + * + * @return the log level + */ + public static LogLevel getLogLevel() { + if (cachedLogLevel != null) { + return cachedLogLevel; + } + String level = getSystemPropOrEnvVar("lucee.debugger.loglevel"); + if (level == null || level.isEmpty()) { + cachedLogLevel = LogLevel.INFO; + } else if ("debug".equalsIgnoreCase(level)) { + cachedLogLevel = LogLevel.DEBUG; + } else if ("error".equalsIgnoreCase(level)) { + cachedLogLevel = LogLevel.ERROR; + } else { + cachedLogLevel = LogLevel.INFO; + } + return cachedLogLevel; + } + + /** + * Check if debug logging is enabled. + * @return true if log level is DEBUG + */ + public static boolean isDebugLoggingEnabled() { + return getLogLevel() == LogLevel.DEBUG; + } +} diff --git a/luceedebug/src/main/java/luceedebug/Log.java b/luceedebug/src/main/java/luceedebug/Log.java new file mode 100644 index 0000000..1b76c81 --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/Log.java @@ -0,0 +1,80 @@ +package luceedebug; + +import org.eclipse.lsp4j.debug.OutputEventArguments; +import org.eclipse.lsp4j.debug.OutputEventArgumentsCategory; +import org.eclipse.lsp4j.debug.services.IDebugProtocolClient; + +/** + * Centralized logging for luceedebug. + * Routes all log messages through a common method that: + * - Writes to System.out with [luceedebug] prefix + * - Optionally sends to DAP OutputEvent when a client is connected + */ +public class Log { + private static final String PREFIX = "[luceedebug] "; + + // DAP client for sending OutputEvents (set when client connects) + private static volatile IDebugProtocolClient dapClient = null; + + /** + * Set the DAP client to receive log messages as OutputEvents. + * Pass null to disable DAP output (e.g., on disconnect). + */ + public static void setDapClient(IDebugProtocolClient client) { + dapClient = client; + } + + /** + * Log a message to console and optionally to DAP client. + */ + public static void info(String message) { + String prefixed = PREFIX + message; + System.out.println(prefixed); + sendToDap(message, OutputEventArgumentsCategory.CONSOLE); + } + + /** + * Log an error message. + */ + public static void error(String message) { + String prefixed = PREFIX + "ERROR: " + message; + System.out.println(prefixed); + sendToDap("ERROR: " + message, OutputEventArgumentsCategory.STDERR); + } + + /** + * Log an error with exception. + */ + public static void error(String message, Throwable t) { + error(message + ": " + t.getMessage()); + t.printStackTrace(); + } + + /** + * Log a debug message (only to console, not to DAP). + * Only printed if LUCEE_DEBUGGER_LOGLEVEL=debug. + */ + public static void debug(String message) { + if (EnvUtil.isDebugLoggingEnabled()) { + System.out.println(PREFIX + "DEBUG: " + message); + } + } + + /** + * Send log to DAP client if connected. + */ + private static void sendToDap(String message, String category) { + IDebugProtocolClient client = dapClient; + if (client != null) { + try { + var args = new OutputEventArguments(); + args.setCategory(category); + args.setOutput("[luceedebug] " + message + "\n"); + client.output(args); + } catch (Exception e) { + // Don't recursively log - just print to console + System.out.println(PREFIX + "Failed to send to DAP: " + e.getMessage()); + } + } + } +} diff --git a/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java b/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java index a684230..25fced4 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java @@ -11,7 +11,6 @@ import com.google.common.cache.CacheBuilder; import lucee.runtime.Component; -import lucee.runtime.exp.PageException; import lucee.runtime.type.Array; import luceedebug.ICfValueDebuggerBridge; import luceedebug.IDebugEntity; @@ -105,6 +104,40 @@ else if (obj instanceof Array && indexedOK) { private static Comparator xscopeByName = Comparator.comparing((IDebugEntity v) -> v.getName().toLowerCase()); + /** + * Check if an object is a "noisy" component function that should be hidden in debug output. + * Uses class name comparison to avoid ClassNotFoundException in OSGi extension mode. + */ + private static boolean isNoisyComponentFunction(Object obj) { + String className = obj.getClass().getName(); + // Discard UDFGetterProperty, UDFSetterProperty, UDFImpl (noisy) + // But retain Lambda and Closure (useful) + boolean isNoisyUdf = className.equals("lucee.runtime.type.UDFGetterProperty") + || className.equals("lucee.runtime.type.UDFSetterProperty") + || className.equals("lucee.runtime.type.UDFImpl"); + boolean isLambdaOrClosure = className.equals("lucee.runtime.type.Lambda") + || className.equals("lucee.runtime.type.Closure"); + return isNoisyUdf && !isLambdaOrClosure; + } + + /** + * Check class by name to avoid ClassNotFoundException in OSGi extension mode. + * Some Lucee core classes aren't visible to the extension classloader. + */ + private static boolean isInstanceOf(Object obj, String className) { + if (obj == null) return false; + Class clazz = obj.getClass(); + while (clazz != null) { + if (clazz.getName().equals(className)) return true; + // Check interfaces + for (Class iface : clazz.getInterfaces()) { + if (iface.getName().equals(className)) return true; + } + clazz = clazz.getSuperclass(); + } + return false; + } + private static IDebugEntity[] getAsMaplike(ValTracker valTracker, Map map) { ArrayList results = new ArrayList<>(); @@ -199,7 +232,7 @@ else if ( // retain the lambbda/closure types var lambda = () => {} // lucee.runtime.type.Lambda var closure = function() {} // lucee.runtime.type.Closure - + // discard component function types, they're mostly noise in debug output component accessors=true { property name="foo"; // lucee.runtime.type.UDFGetterProperty / lucee.runtime.type.UDFSetterProperty @@ -207,29 +240,26 @@ function foo() {} // lucee.runtime.type.UDFImpl } */ skipNoisyComponentFunctions - && (obj instanceof lucee.runtime.type.UDFGetterProperty - || obj instanceof lucee.runtime.type.UDFSetterProperty - || obj instanceof lucee.runtime.type.UDFImpl) - && !( - obj instanceof lucee.runtime.type.Lambda - || obj instanceof lucee.runtime.type.Closure - ) + && isNoisyComponentFunction(obj) ) { return null; } - else if (obj instanceof lucee.runtime.type.QueryImpl) { + else if (isInstanceOf(obj, "lucee.runtime.type.QueryImpl")) { + // Handle Query - use reflection to avoid ClassNotFoundException in OSGi try { - lucee.runtime.type.query.QueryArray queryAsArrayOfStructs = lucee.runtime.type.query.QueryArray.toQueryArray((lucee.runtime.type.QueryImpl)obj); - val.value = "Query (" + queryAsArrayOfStructs.size() + " rows)"; - + java.lang.reflect.Method toQueryArrayMethod = Class.forName("lucee.runtime.type.query.QueryArray", true, obj.getClass().getClassLoader()) + .getMethod("toQueryArray", Class.forName("lucee.runtime.type.QueryImpl", true, obj.getClass().getClassLoader())); + Object queryAsArrayOfStructs = toQueryArrayMethod.invoke(null, obj); + java.lang.reflect.Method sizeMethod = queryAsArrayOfStructs.getClass().getMethod("size"); + int size = (int) sizeMethod.invoke(queryAsArrayOfStructs); + val.value = "Query (" + size + " rows)"; + pin(queryAsArrayOfStructs); val.variablesReference = valTracker.idempotentRegisterObject(queryAsArrayOfStructs).id; } - catch (PageException e) { - // - // duplicative w/ catch-all else block - // + catch (Throwable e) { + // Fall back to generic display try { val.value = obj.getClass().toString(); val.variablesReference = valTracker.idempotentRegisterObject(obj).id; @@ -282,7 +312,7 @@ public int getNamedVariablesCount() { } public int getIndexedVariablesCount() { - if (obj instanceof lucee.runtime.type.scope.Argument) { + if (isInstanceOf(obj, "lucee.runtime.type.scope.Argument")) { // `arguments` scope is both an Array and a Map, which represents the possiblity that a function is called with named args or positional args. // It seems like saner default behavior to report it only as having named variables, and zero indexed variables. return 0; @@ -302,11 +332,33 @@ public static String getSourcePath(Object obj) { if (obj instanceof Component) { return ((Component)obj).getPageSource().getPhyscalFile().getAbsolutePath(); } - else if (obj instanceof lucee.runtime.type.UDFImpl) { - return ((lucee.runtime.type.UDFImpl)obj).properties.getPageSource().getPhyscalFile().getAbsolutePath(); + else if (isInstanceOf(obj, "lucee.runtime.type.UDFImpl")) { + // Use reflection to avoid ClassNotFoundException in OSGi + try { + java.lang.reflect.Field propsField = obj.getClass().getField("properties"); + Object props = propsField.get(obj); + java.lang.reflect.Method getPageSourceMethod = props.getClass().getMethod("getPageSource"); + Object pageSource = getPageSourceMethod.invoke(props); + java.lang.reflect.Method getPhyscalFileMethod = pageSource.getClass().getMethod("getPhyscalFile"); + Object file = getPhyscalFileMethod.invoke(pageSource); + java.lang.reflect.Method getAbsolutePathMethod = file.getClass().getMethod("getAbsolutePath"); + return (String) getAbsolutePathMethod.invoke(file); + } catch (Throwable e) { + return null; + } } - else if (obj instanceof lucee.runtime.type.UDFGSProperty) { - return ((lucee.runtime.type.UDFGSProperty)obj).getPageSource().getPhyscalFile().getAbsolutePath(); + else if (isInstanceOf(obj, "lucee.runtime.type.UDFGSProperty")) { + // Use reflection to avoid ClassNotFoundException in OSGi + try { + java.lang.reflect.Method getPageSourceMethod = obj.getClass().getMethod("getPageSource"); + Object pageSource = getPageSourceMethod.invoke(obj); + java.lang.reflect.Method getPhyscalFileMethod = pageSource.getClass().getMethod("getPhyscalFile"); + Object file = getPhyscalFileMethod.invoke(pageSource); + java.lang.reflect.Method getAbsolutePathMethod = file.getClass().getMethod("getAbsolutePath"); + return (String) getAbsolutePathMethod.invoke(file); + } catch (Throwable e) { + return null; + } } else { return null; diff --git a/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java b/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java index df407fd..3e1b227 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java @@ -13,8 +13,6 @@ import java.util.concurrent.TimeUnit; import java.util.function.Supplier; -import javax.servlet.ServletException; - import com.google.common.collect.MapMaker; import com.sun.jdi.Bootstrap; import com.sun.jdi.VirtualMachine; @@ -197,7 +195,7 @@ public PageContextAndOutputStream(PageContext pageContext, ByteArrayOutputStream } // is there a way to conjure up a new PageContext without having some other page context? - public static PageContextAndOutputStream ephemeralPageContextFromOther(PageContext pc) throws ServletException { + public static PageContextAndOutputStream ephemeralPageContextFromOther(PageContext pc) throws Exception { final var outputStream = new ByteArrayOutputStream(); PageContext freshEphemeralPageContext = lucee.runtime.util.PageContextUtil.getPageContext( /*Config config*/ pc.getConfig(), @@ -206,7 +204,7 @@ public static PageContextAndOutputStream ephemeralPageContextFromOther(PageConte /*String host*/ "", /*String scriptName*/ "", /*String queryString*/ "", - /*Cookie[] cookies*/ new javax.servlet.http.Cookie[] {}, + /*Cookie[] cookies*/ null, /*Map headers*/ new HashMap<>(), /*Map parameters*/ new HashMap<>(), /*Map attributes*/ new HashMap<>(), @@ -514,7 +512,8 @@ synchronized public IDebugFrame[] getCfStack(Thread thread) { } if (pc != null) { - IDebugFrame[] nativeFrames = NativeDebugFrame.getNativeFrames(pc, valTracker); + // In agent mode, pass null for classloader - classes are injected into Lucee's classloader + IDebugFrame[] nativeFrames = NativeDebugFrame.getNativeFrames(pc, valTracker, -1, null); if (nativeFrames != null && nativeFrames.length > 0) { return nativeFrames; } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java index 890f742..0bfed5f 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java @@ -7,6 +7,7 @@ import lucee.runtime.PageContext; import luceedebug.Config; +import luceedebug.Log; /** * Step mode for stepping operations. @@ -51,6 +52,37 @@ public class NativeDebuggerListener { */ private static final ConcurrentHashMap> nativelySuspendedThreads = new ConcurrentHashMap<>(); + /** + * Suspend location info for threads (file and line where suspended). + * Used when there are no native DebuggerFrames (top-level code). + */ + private static final ConcurrentHashMap suspendLocations = new ConcurrentHashMap<>(); + + /** + * Simple holder for file/line/label at suspend point. + */ + public static class SuspendLocation { + public final String file; + public final int line; + public final String label; + public final Throwable exception; // non-null if suspended due to exception + public SuspendLocation( String file, int line, String label ) { + this( file, line, label, null ); + } + public SuspendLocation( String file, int line, String label, Throwable exception ) { + this.file = file; + this.line = line; + this.label = label; + this.exception = exception; + } + } + + /** + * Pending exception for a thread - stored when onException returns true, + * then consumed when onSuspend is called. + */ + private static final ConcurrentHashMap pendingExceptions = new ConcurrentHashMap<>(); + /** * Callback to notify LuceeVm when a thread suspends via native breakpoint. * Called with Java thread ID and optional label. Used for "breakpoint" stop reason in DAP. @@ -70,6 +102,19 @@ public class NativeDebuggerListener { */ private static volatile boolean nativeOnlyMode = false; + /** + * Flag indicating a DAP client is actually connected. + * Set to true when DAP client connects, false when it disconnects. + * Distinct from callbacks being registered (which happens at extension startup). + */ + private static volatile boolean dapClientConnected = false; + + /** + * Flag to break on uncaught exceptions. + * Set via DAP setExceptionBreakpoints request. + */ + private static volatile boolean breakOnUncaughtExceptions = false; + /** * Per-thread stepping state. */ @@ -93,7 +138,7 @@ private static class StepState { */ public static void setNativeOnlyMode(boolean enabled) { nativeOnlyMode = enabled; - System.out.println("[luceedebug] Native-only mode: " + enabled); + Log.info("Native-only mode: " + enabled); } /** @@ -135,10 +180,10 @@ public static void addBreakpoint(String file, int line, String condition) { breakpoints.put(key, Boolean.TRUE); if (condition != null && !condition.isEmpty()) { breakpointConditions.put(key, condition); - System.out.println("[luceedebug] Added native breakpoint: " + key + " condition=" + condition); + Log.info("Added native breakpoint: " + key + " condition=" + condition); } else { breakpointConditions.remove(key); // ensure no stale condition - System.out.println("[luceedebug] Added native breakpoint: " + key); + Log.info("Added native breakpoint: " + key); } } @@ -149,7 +194,7 @@ public static void removeBreakpoint(String file, int line) { String key = makeKey(file, line); breakpoints.remove(key); breakpointConditions.remove(key); - System.out.println("[luceedebug] Removed native breakpoint: " + key); + Log.info("Removed native breakpoint: " + key); } /** @@ -159,7 +204,7 @@ public static void clearBreakpointsForFile(String file) { String prefix = Config.canonicalizeFileName(file) + ":"; breakpoints.keySet().removeIf(key -> key.startsWith(prefix)); breakpointConditions.keySet().removeIf(key -> key.startsWith(prefix)); - System.out.println("[luceedebug] Cleared native breakpoints for: " + file); + Log.info("Cleared native breakpoints for: " + file); } /** @@ -168,7 +213,7 @@ public static void clearBreakpointsForFile(String file) { public static void clearAllBreakpoints() { breakpoints.clear(); breakpointConditions.clear(); - System.out.println("[luceedebug] Cleared all native breakpoints"); + Log.info("Cleared all native breakpoints"); } /** @@ -185,6 +230,14 @@ public static boolean isNativelySuspended(long javaThreadId) { return nativelySuspendedThreads.containsKey(javaThreadId); } + /** + * Get all suspended thread IDs. + * Used by NativeLuceeVm to include suspended threads in thread listing. + */ + public static java.util.Set getSuspendedThreadIds() { + return new java.util.HashSet<>(nativelySuspendedThreads.keySet()); + } + /** * Get any PageContext from the suspended threads map. * Used to bootstrap access to CFMLFactory for thread listing. @@ -200,6 +253,30 @@ public static PageContext getAnyPageContext() { return null; } + /** + * Get PageContext for a specific suspended thread. + * Used by NativeLuceeVm to get stack frames. + * @param javaThreadId The Java thread ID + * @return the PageContext if found and still valid, null otherwise + */ + public static PageContext getPageContext(long javaThreadId) { + WeakReference ref = nativelySuspendedThreads.get(javaThreadId); + if (ref != null) { + return ref.get(); + } + return null; + } + + /** + * Get suspend location for a specific thread. + * Used to create synthetic frame for top-level code. + * @param javaThreadId The Java thread ID + * @return the SuspendLocation if thread is suspended, null otherwise + */ + public static SuspendLocation getSuspendLocation(long javaThreadId) { + return suspendLocations.get(javaThreadId); + } + /** * Resume a natively suspended thread by calling debuggerResume() on its PageContext. * Uses reflection since debuggerResume() is a Lucee7+ method not in the loader interface. @@ -214,18 +291,17 @@ public static boolean resumeNativeThread(long javaThreadId) { if (pc == null) { return false; } - System.out.println("[luceedebug] Resuming native thread: " + javaThreadId); + Log.info("Resuming native thread: " + javaThreadId); try { // Call debuggerResume() via reflection (Lucee7+ method) java.lang.reflect.Method resumeMethod = pc.getClass().getMethod("debuggerResume"); resumeMethod.invoke(pc); return true; } catch (NoSuchMethodException e) { - System.out.println("[luceedebug] debuggerResume() not available (pre-Lucee7?)"); + Log.error("debuggerResume() not available (pre-Lucee7?)"); return false; } catch (Exception e) { - System.out.println("[luceedebug] Error calling debuggerResume(): " + e.getMessage()); - e.printStackTrace(); + Log.error("Error calling debuggerResume()", e); return false; } } @@ -249,7 +325,7 @@ public static void resumeAllNativeThreads() { */ public static void startStepping(long threadId, StepMode mode, int currentDepth) { steppingThreads.put(threadId, new StepState(mode, currentDepth)); - System.out.println("[luceedebug] Start stepping: thread=" + threadId + " mode=" + mode + " depth=" + currentDepth); + Log.info("Start stepping: thread=" + threadId + " mode=" + mode + " depth=" + currentDepth); } /** @@ -270,7 +346,7 @@ public static int getStackDepth(PageContext pc) { return frames != null ? frames.length : 0; } catch (Exception e) { // Log error - silent failure could cause incorrect step behavior - System.out.println("[luceedebug] Error getting stack depth: " + e.getMessage()); + Log.error("Error getting stack depth: " + e.getMessage()); return 0; } } @@ -283,7 +359,7 @@ public static int getStackDepth(PageContext pc) { */ public static void onSuspend(PageContext pc, String file, int line, String label) { long threadId = Thread.currentThread().getId(); - System.out.println("[luceedebug] Native suspend: thread=" + threadId + " file=" + file + " line=" + line + " label=" + label); + Log.info("Native suspend: thread=" + threadId + " file=" + file + " line=" + line + " label=" + label); // Check if we were stepping BEFORE clearing state StepState stepState = steppingThreads.remove(threadId); @@ -292,10 +368,17 @@ public static void onSuspend(PageContext pc, String file, int line, String label // Check if we hit a breakpoint (breakpoint wins over step) boolean hitBreakpoint = breakpoints.containsKey(makeKey(file, line)); + // Check if there's a pending exception for this thread (from onException) + Throwable pendingException = pendingExceptions.remove(threadId); + // Track the suspended thread so we can resume it later // We store PageContext (not PageContextImpl) to avoid class loading cycles nativelySuspendedThreads.put(threadId, new WeakReference<>(pc)); + // Store suspend location for stack trace (needed when no native DebuggerFrames exist) + // Include the exception if we're suspending due to one + suspendLocations.put(threadId, new SuspendLocation(file, line, label, pendingException)); + // Fire appropriate callback - breakpoint takes precedence over step if (hitBreakpoint) { // Stopped at breakpoint (no label for line breakpoints) @@ -323,10 +406,69 @@ public static void onSuspend(PageContext pc, String file, int line, String label */ public static void onResume(PageContext pc) { long threadId = Thread.currentThread().getId(); - System.out.println("[luceedebug] Native resume: thread=" + threadId); + Log.info("Native resume: thread=" + threadId); - // Remove from suspended threads map + // Remove from suspended threads map and location nativelySuspendedThreads.remove(threadId); + suspendLocations.remove(threadId); + } + + /** + * Check if a DAP client is connected and ready to handle breakpoints. + * Uses explicit flag set when DAP client connects, not just callback registration. + */ + public static boolean isDapClientConnected() { + return dapClientConnected; + } + + /** + * Set the DAP client connected state. + * Called by DapServer when client connects/disconnects. + */ + public static void setDapClientConnected(boolean connected) { + dapClientConnected = connected; + Log.info("DAP client connected: " + connected); + } + + /** + * Set whether to break on uncaught exceptions. + * Called from DapServer when handling setExceptionBreakpoints request. + */ + public static void setBreakOnUncaughtExceptions(boolean enabled) { + breakOnUncaughtExceptions = enabled; + Log.info("Break on uncaught exceptions: " + enabled); + } + + /** + * Check if we should break on uncaught exceptions. + */ + public static boolean shouldBreakOnUncaughtExceptions() { + return breakOnUncaughtExceptions && dapClientConnected; + } + + /** + * Called by Lucee when an exception is about to be handled. + * Returns true if we should suspend to let the debugger inspect. + * + * @param pc The PageContext + * @param exception The exception + * @param caught true if caught by try/catch, false if uncaught + * @return true to suspend execution + */ + public static boolean onException(PageContext pc, Throwable exception, boolean caught) { + Log.info("onException called: caught=" + caught + ", exception=" + exception.getClass().getName() + ", breakOnUncaught=" + breakOnUncaughtExceptions + ", dapConnected=" + dapClientConnected); + // Only handle uncaught exceptions for now + if (caught) { + return false; + } + boolean shouldSuspend = shouldBreakOnUncaughtExceptions(); + if (shouldSuspend) { + // Store exception for this thread - will be consumed in onSuspend + long threadId = Thread.currentThread().getId(); + pendingExceptions.put(threadId, exception); + } + Log.info("onException returning: " + shouldSuspend); + return shouldSuspend; } /** @@ -335,6 +477,11 @@ public static void onResume(PageContext pc) { * Must be fast - this is on the hot path. */ public static boolean shouldSuspend(PageContext pc, String file, int line) { + // Early exit if no DAP client connected - nothing to notify + if (!dapClientConnected) { + return false; + } + // Check breakpoints first (most common case) String key = makeKey(file, line); if (breakpoints.containsKey(key)) { @@ -389,7 +536,7 @@ private static boolean evaluateCondition(PageContext pc, String condition) { } catch (Exception e) { // Condition evaluation failed - don't suspend // Log but don't spam - conditions may intentionally reference undefined vars - System.out.println("[luceedebug] Condition evaluation failed: " + e.getMessage()); + Log.debug("Condition evaluation failed: " + e.getMessage()); return false; } } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java index c27345a..536015b 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java @@ -1,11 +1,16 @@ package luceedebug.coreinject; +import java.lang.ref.Cleaner; import java.util.ArrayList; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Consumer; +import lucee.runtime.PageContext; + import luceedebug.*; +import luceedebug.coreinject.frame.NativeDebugFrame; import luceedebug.strong.DapBreakpointID; import luceedebug.strong.CanonicalServerAbsPath; import luceedebug.strong.RawIdePath; @@ -20,6 +25,9 @@ public class NativeLuceeVm implements ILuceeVm { private final Config config_; + private static ClassLoader luceeClassLoader; + private static final Cleaner cleaner = Cleaner.create(); + private final ValTracker valTracker = new ValTracker(cleaner); private Consumer stepEventCallback = null; private BiConsumer breakpointEventCallback = null; @@ -28,6 +36,17 @@ public class NativeLuceeVm implements ILuceeVm { private AtomicInteger breakpointID = new AtomicInteger(); + // Cache of frame ID -> frame for scope/variable lookups + private final ConcurrentHashMap frameCache = new ConcurrentHashMap<>(); + + /** + * Set the Lucee classloader for reflection access to Lucee core classes. + * Must be called before creating NativeLuceeVm in extension mode. + */ + public static void setLuceeClassLoader(ClassLoader cl) { + luceeClassLoader = cl; + } + public NativeLuceeVm(Config config) { this.config_ = config; @@ -81,54 +100,88 @@ public void registerBreakpointsChangedCallback(Consumer @Override public ThreadInfo[] getThreadListing() { var result = new ArrayList(); - - // Get active PageContexts from Lucee's CFMLFactory - // We need a PageContext to get the factory - try ThreadLocalPageContext or suspended threads - lucee.runtime.PageContext anyPc = lucee.runtime.engine.ThreadLocalPageContext.get(); - - // If not on a request thread, try to get from suspended threads - if (anyPc == null) { - anyPc = NativeDebuggerListener.getAnyPageContext(); + var seenThreadIds = new java.util.HashSet(); + + // First, add any suspended threads (these are most important for debugging) + for (Long threadId : NativeDebuggerListener.getSuspendedThreadIds()) { + Thread thread = findThreadById(threadId); + if (thread != null) { + result.add(new ThreadInfo(thread.getId(), thread.getName() + " (suspended)")); + seenThreadIds.add(threadId); + } } - if (anyPc != null) { - try { - // Get the CFMLFactory from the PageContext - Object factory = anyPc.getCFMLFactory(); - - // Call getActivePageContexts() via reflection (it's in CFMLFactoryImpl, not loader) - java.lang.reflect.Method getActiveMethod = factory.getClass().getMethod("getActivePageContexts"); - @SuppressWarnings("unchecked") - java.util.Map activeContexts = (java.util.Map) getActiveMethod.invoke(factory); - - // Each PageContext has a getThread() method - for (Object pc : activeContexts.values()) { - try { - java.lang.reflect.Method getThreadMethod = pc.getClass().getMethod("getThread"); - Thread thread = (Thread) getThreadMethod.invoke(pc); - if (thread != null) { - result.add(new ThreadInfo(thread.getId(), thread.getName())); + try { + // Get CFMLEngine via the loader's factory (available to extension classloader) + Object engine = lucee.loader.engine.CFMLEngineFactory.getInstance(); + + // The factory returns CFMLEngineWrapper - unwrap to get CFMLEngineImpl + java.lang.reflect.Method getEngineMethod = engine.getClass().getMethod("getEngine"); + Object engineImpl = getEngineMethod.invoke(engine); + + // Get all CFMLFactory instances from the engine impl + // CFMLEngineImpl has getCFMLFactories() returning Map + java.lang.reflect.Method getFactoriesMethod = engineImpl.getClass().getMethod("getCFMLFactories"); + @SuppressWarnings("unchecked") + java.util.Map factoriesMap = (java.util.Map) getFactoriesMethod.invoke(engineImpl); + Object[] factories = factoriesMap.values().toArray(); + + for (Object factory : factories) { + try { + // Call getActivePageContexts() - it's in CFMLFactoryImpl + java.lang.reflect.Method getActiveMethod = factory.getClass().getMethod("getActivePageContexts"); + @SuppressWarnings("unchecked") + java.util.Map activeContexts = (java.util.Map) getActiveMethod.invoke(factory); + + // Each PageContext has a getThread() method + for (Object pc : activeContexts.values()) { + try { + java.lang.reflect.Method getThreadMethod = pc.getClass().getMethod("getThread"); + Thread thread = (Thread) getThreadMethod.invoke(pc); + if (thread != null && !seenThreadIds.contains(thread.getId())) { + result.add(new ThreadInfo(thread.getId(), thread.getName())); + seenThreadIds.add(thread.getId()); + } + } catch (Exception e) { + // Skip this context if we can't get its thread } - } catch (Exception e) { - // Skip this context if we can't get its thread } + } catch (Exception e) { + // Skip this factory } - } catch (Exception e) { - System.out.println("[luceedebug] Error getting active page contexts: " + e.getMessage()); } + } catch (Exception e) { + Log.error("Error getting thread listing", e); } + Log.debug("Thread listing: " + result.size() + " threads"); return result.toArray(new ThreadInfo[0]); } @Override public IDebugFrame[] getStackTrace(long threadID) { - // Get the thread and use DebugManager to get CF stack - Thread thread = findThreadById(threadID); - if (thread == null) { + // In native mode, get frames from the suspended thread's PageContext + PageContext pc = NativeDebuggerListener.getPageContext(threadID); + if (pc == null) { + Log.debug("getStackTrace: no PageContext for thread " + threadID); + return new IDebugFrame[0]; + } + + // Use NativeDebugFrame to get the CFML stack from PageContext + // Pass threadID so it can create synthetic frame for top-level code + IDebugFrame[] frames = NativeDebugFrame.getNativeFrames(pc, valTracker, threadID, luceeClassLoader); + if (frames == null) { + Log.debug("getStackTrace: no native frames for thread " + threadID); return new IDebugFrame[0]; } - return GlobalIDebugManagerHolder.debugManager.getCfStack(thread); + + // Cache frames for later scope/variable lookups + for (IDebugFrame frame : frames) { + frameCache.put(frame.getId(), frame); + } + + Log.debug("getStackTrace: returning " + frames.length + " frames for thread " + threadID); + return frames; } private Thread findThreadById(long threadId) { @@ -144,22 +197,39 @@ private Thread findThreadById(long threadId) { @Override public IDebugEntity[] getScopes(long frameID) { - return GlobalIDebugManagerHolder.debugManager.getScopesForFrame(frameID); + // Look up frame from cache + IDebugFrame frame = frameCache.get(frameID); + if (frame == null) { + Log.debug("getScopes: frame " + frameID + " not found in cache"); + return new IDebugEntity[0]; + } + return frame.getScopes(); } @Override public IDebugEntity[] getVariables(long ID) { - return GlobalIDebugManagerHolder.debugManager.getVariables(ID, null); + return getVariablesImpl(ID, null); } @Override public IDebugEntity[] getNamedVariables(long ID) { - return GlobalIDebugManagerHolder.debugManager.getVariables(ID, IDebugEntity.DebugEntityType.NAMED); + return getVariablesImpl(ID, IDebugEntity.DebugEntityType.NAMED); } @Override public IDebugEntity[] getIndexedVariables(long ID) { - return GlobalIDebugManagerHolder.debugManager.getVariables(ID, IDebugEntity.DebugEntityType.INDEXED); + return getVariablesImpl(ID, IDebugEntity.DebugEntityType.INDEXED); + } + + private IDebugEntity[] getVariablesImpl(long variablesReference, IDebugEntity.DebugEntityType which) { + // Look up the object by its variablesReference ID + var maybeObj = valTracker.maybeGetFromId(variablesReference); + if (maybeObj.isEmpty()) { + Log.debug("getVariables: variablesReference " + variablesReference + " not found"); + return new IDebugEntity[0]; + } + Object obj = maybeObj.get().obj; + return CfValueDebuggerBridge.getAsDebugEntity(valTracker, obj, which); } // ========== Breakpoint operations ========== @@ -260,6 +330,11 @@ public String getSourcePathForVariablesRef(int variablesRef) { @Override public Either> evaluate(int frameID, String expr) { + // In native mode, GlobalIDebugManagerHolder.debugManager is null + // TODO: Implement native evaluation using PageContext + if (GlobalIDebugManagerHolder.debugManager == null) { + return Either.Left("Expression evaluation not yet supported in native debugger mode"); + } return GlobalIDebugManagerHolder.debugManager.evaluate((Long)(long)frameID, expr); } } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/ValTracker.java b/luceedebug/src/main/java/luceedebug/coreinject/ValTracker.java index 3fe4d77..994ab2f 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/ValTracker.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/ValTracker.java @@ -22,7 +22,8 @@ public class ValTracker { private final Map wrapperByID = new ConcurrentHashMap<>(); private static class WeakTaggedObject { - private static final AtomicLong nextId = new AtomicLong(); + // Start at 1, not 0 - DAP uses variablesReference=0 to mean "no children" + private static final AtomicLong nextId = new AtomicLong(1); public final long id; public final WeakReference wrapped; public WeakTaggedObject(Object obj) { diff --git a/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java index c1652a5..bef9265 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java @@ -1,7 +1,6 @@ package luceedebug.coreinject.frame; import lucee.runtime.PageContext; -import lucee.runtime.PageContextImpl; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -28,7 +27,6 @@ public class NativeDebugFrame implements IDebugFrame { // Reflection cache - initialized once private static volatile Boolean nativeFrameSupportAvailable = null; - private static Field debuggerEnabledField = null; private static Method getDebuggerFramesMethod = null; private static Method getLineMethod = null; private static Method setLineMethod = null; @@ -39,13 +37,15 @@ public class NativeDebugFrame implements IDebugFrame { private static Field functionNameField = null; private static Method getDisplayPathMethod = null; - private final Object nativeFrame; // PageContextImpl.DebuggerFrame + private final Object nativeFrame; // PageContextImpl.DebuggerFrame (null for synthetic top-level frame) private final PageContext pageContext; private final ValTracker valTracker; private final String sourceFilePath; private final String functionName; private final long id; private final int depth; + private int syntheticLine; // For synthetic frames only + private final Throwable exception; // Non-null if this frame is for an exception suspend // Scope references from the native frame private final Object local; // lucee.runtime.type.scope.Local @@ -55,12 +55,14 @@ public class NativeDebugFrame implements IDebugFrame { // lazy initialized on request for scopes private LinkedHashMap scopes_ = null; - private NativeDebugFrame( Object nativeFrame, PageContext pageContext, ValTracker valTracker, int depth ) throws Exception { + // Constructor for real native frames (wrapping DebuggerFrame) + private NativeDebugFrame( Object nativeFrame, PageContext pageContext, ValTracker valTracker, int depth, Throwable exception ) throws Exception { this.nativeFrame = nativeFrame; this.pageContext = pageContext; this.valTracker = valTracker; this.id = nextId.incrementAndGet(); this.depth = depth; + this.exception = exception; // Extract fields using reflection this.local = localField.get( nativeFrame ); @@ -71,6 +73,67 @@ private NativeDebugFrame( Object nativeFrame, PageContext pageContext, ValTracke this.functionName = (String) functionNameField.get( nativeFrame ); } + // Constructor for synthetic top-level frame (no DebuggerFrame exists) + private NativeDebugFrame( PageContext pageContext, ValTracker valTracker, String file, int line, String label, Throwable exception ) { + this.nativeFrame = null; // synthetic - no native frame + this.pageContext = pageContext; + this.valTracker = valTracker; + this.id = nextId.incrementAndGet(); + this.depth = 0; + this.syntheticLine = line; + this.sourceFilePath = file; + this.exception = exception; + + // Build frame name - use label if provided, otherwise try to get request URL + if ( label != null && !label.isEmpty() ) { + this.functionName = label; + } else { + // Try to get request URL for more useful frame name + String requestUrl = getRequestUrl( pageContext ); + this.functionName = (requestUrl != null) ? requestUrl : ""; + } + + // For top-level code, use PageContext scopes directly + this.local = null; + this.arguments = null; + try { + this.variables = pageContext.variablesScope(); + } catch ( Exception e ) { + throw new RuntimeException( e ); + } + } + + /** + * Try to get the request URL from PageContext's CGI scope. + */ + private static String getRequestUrl( PageContext pc ) { + try { + Object cgiScope = pc.cgiScope(); + if ( cgiScope instanceof Map ) { + @SuppressWarnings("unchecked") + Map cgi = (Map) cgiScope; + // Try script_name first (just the path), then request_url + Object scriptName = cgi.get( "script_name" ); + if ( scriptName == null ) { + // Try with Key object if direct string lookup fails + for ( Map.Entry entry : cgi.entrySet() ) { + String keyStr = entry.getKey().toString().toLowerCase(); + if ( "script_name".equals( keyStr ) ) { + scriptName = entry.getValue(); + break; + } + } + } + if ( scriptName != null && !scriptName.toString().isEmpty() ) { + return scriptName.toString(); + } + } + } catch ( Exception e ) { + // Ignore - fall back to default + } + return null; + } + @Override public String getSourceFilePath() { return sourceFilePath; @@ -83,7 +146,14 @@ public long getId() { @Override public String getName() { - return functionName != null ? functionName : "??"; + if ( functionName == null ) { + return "??"; + } + // Don't add () for synthetic frames (start with < or /) or exception labels + if ( functionName.startsWith( "<" ) || functionName.startsWith( "/" ) || functionName.contains( ":" ) ) { + return functionName; + } + return functionName + "()"; } @Override @@ -93,6 +163,10 @@ public int getDepth() { @Override public int getLine() { + if ( nativeFrame == null ) { + // Synthetic frame - return stored line + return syntheticLine; + } try { return (int) getLineMethod.invoke( nativeFrame ); } catch ( Exception e ) { @@ -102,6 +176,11 @@ public int getLine() { @Override public void setLine( int line ) { + if ( nativeFrame == null ) { + // Synthetic frame - update stored line + syntheticLine = line; + return; + } try { setLineMethod.invoke( nativeFrame, line ); } catch ( Exception e ) { @@ -124,6 +203,11 @@ private void lazyInitScopeRefs() { scopes_ = new LinkedHashMap<>(); + // If this frame has an exception, add cfcatch scope first (most relevant when debugging exceptions) + if ( exception != null ) { + addCfcatchScope(); + } + // Frame-specific scopes from native DebuggerFrame checkedPutScopeRef( "local", local ); checkedPutScopeRef( "arguments", arguments ); @@ -166,6 +250,62 @@ private void lazyInitScopeRefs() { } catch ( Throwable e ) { /* scope not available */ } } + /** + * Add a cfcatch scope with exception details. + * Mimics the structure of CFML's cfcatch variable. + */ + private void addCfcatchScope() { + // Build a map with cfcatch-like properties + Map cfcatch = new LinkedHashMap<>(); + + // Basic exception properties + cfcatch.put( "type", getExceptionType( exception ) ); + cfcatch.put( "message", exception.getMessage() != null ? exception.getMessage() : "" ); + + // Get detail if it's a PageException + String detail = ""; + String errorCode = ""; + String extendedInfo = ""; + if ( exception instanceof lucee.runtime.exp.PageException ) { + lucee.runtime.exp.PageException pe = (lucee.runtime.exp.PageException) exception; + detail = pe.getDetail() != null ? pe.getDetail() : ""; + errorCode = pe.getErrorCode() != null ? pe.getErrorCode() : ""; + extendedInfo = pe.getExtendedInfo() != null ? pe.getExtendedInfo() : ""; + } + cfcatch.put( "detail", detail ); + cfcatch.put( "errorCode", errorCode ); + cfcatch.put( "extendedInfo", extendedInfo ); + + // Java exception info + cfcatch.put( "javaClass", exception.getClass().getName() ); + + // Stack trace as string + java.io.StringWriter sw = new java.io.StringWriter(); + exception.printStackTrace( new java.io.PrintWriter( sw ) ); + cfcatch.put( "stackTrace", sw.toString() ); + + // Add as scope - pin both the wrapper and the inner map to prevent GC + var v = new MarkerTrait.Scope( cfcatch ); + CfValueDebuggerBridge.pin( cfcatch ); + CfValueDebuggerBridge.pin( v ); + scopes_.put( "cfcatch", new CfValueDebuggerBridge( valTracker, v ) ); + } + + /** + * Get the CFML-style type for an exception. + */ + private String getExceptionType( Throwable t ) { + if ( t instanceof lucee.runtime.exp.PageException ) { + lucee.runtime.exp.PageException pe = (lucee.runtime.exp.PageException) t; + String type = pe.getTypeAsString(); + if ( type != null && !type.isEmpty() ) { + return type; + } + } + // Fall back to Java exception type + return t.getClass().getSimpleName(); + } + @Override public IDebugEntity[] getScopes() { lazyInitScopeRefs(); @@ -189,28 +329,35 @@ public IDebugEntity[] getScopes() { /** * Initialize reflection handles for Lucee7's native debugger frame support. * Returns true if initialization succeeded (Lucee7 with DEBUGGER_ENABLED=true). + * @param luceeClassLoader ClassLoader to use for loading Lucee core classes (required in OSGi extension mode) */ - private static synchronized boolean initReflection() { + private static synchronized boolean initReflection( ClassLoader luceeClassLoader ) { if ( nativeFrameSupportAvailable != null ) { return nativeFrameSupportAvailable; } try { - Class pciClass = PageContextImpl.class; - - // Check if DEBUGGER_ENABLED field exists and is true - debuggerEnabledField = pciClass.getField( "DEBUGGER_ENABLED" ); - boolean enabled = debuggerEnabledField.getBoolean( null ); - if ( !enabled ) { + // Check if DEBUGGER_ENABLED is true (via env var) + if ( !EnvUtil.isDebuggerEnabled() ) { + Log.info( "Native frame support disabled: LUCEE_DEBUGGER_ENABLED not set" ); nativeFrameSupportAvailable = false; return false; } + // Use provided classloader, fall back to PageContext's classloader + ClassLoader cl = luceeClassLoader; + if ( cl == null ) { + cl = PageContext.class.getClassLoader(); + } + + // Load PageContextImpl via reflection (not directly accessible in OSGi extension mode) + Class pciClass = cl.loadClass( "lucee.runtime.PageContextImpl" ); + // Get the getDebuggerFrames method getDebuggerFramesMethod = pciClass.getMethod( "getDebuggerFrames" ); // Get DebuggerFrame class (inner class of PageContextImpl) - Class debuggerFrameClass = Class.forName( "lucee.runtime.PageContextImpl$DebuggerFrame" ); + Class debuggerFrameClass = cl.loadClass( "lucee.runtime.PageContextImpl$DebuggerFrame" ); // Get DebuggerFrame fields and methods localField = debuggerFrameClass.getField( "local" ); @@ -222,15 +369,16 @@ private static synchronized boolean initReflection() { setLineMethod = debuggerFrameClass.getMethod( "setLine", int.class ); // Get PageSource.getDisplayPath method - Class pageSourceClass = Class.forName( "lucee.runtime.PageSource" ); + Class pageSourceClass = cl.loadClass( "lucee.runtime.PageSource" ); getDisplayPathMethod = pageSourceClass.getMethod( "getDisplayPath" ); nativeFrameSupportAvailable = true; - System.out.println( "[luceedebug] Native Lucee7 debugger frame support detected and enabled" ); + Log.info( "Native Lucee7 debugger frame support detected and enabled" ); return true; } catch ( Throwable e ) { // Lucee version doesn't have native debugger frame support + Log.error( "Failed to initialize native frame support: " + e.getMessage() ); nativeFrameSupportAvailable = false; return false; } @@ -239,42 +387,62 @@ private static synchronized boolean initReflection() { /** * Check if native debugger frames are available in this Lucee version. * Returns true if DEBUGGER_ENABLED is true in Lucee7+. + * @param luceeClassLoader ClassLoader to use for loading Lucee core classes */ - public static boolean isNativeFrameSupportAvailable() { - return initReflection(); + public static boolean isNativeFrameSupportAvailable( ClassLoader luceeClassLoader ) { + return initReflection( luceeClassLoader ); } /** * Get frames from Lucee's native debugger frame stack. - * Returns null if native frames are not available or empty. + * If no native DebuggerFrames exist (top-level code), creates a synthetic frame using the suspend location. + * @param pageContext The PageContext + * @param valTracker Value tracker for scope references + * @param threadId Java thread ID to look up suspend location (for synthetic frames) + * @param luceeClassLoader ClassLoader to use for loading Lucee core classes + * @return Array of debug frames, or null if not available */ - public static IDebugFrame[] getNativeFrames( PageContext pageContext, ValTracker valTracker ) { - if ( !isNativeFrameSupportAvailable() ) { + public static IDebugFrame[] getNativeFrames( PageContext pageContext, ValTracker valTracker, long threadId, ClassLoader luceeClassLoader ) { + if ( !isNativeFrameSupportAvailable( luceeClassLoader ) ) { + Log.debug( "getNativeFrames: native frame support not available" ); return null; } try { - PageContextImpl pci = (PageContextImpl) pageContext; - Object[] nativeFrames = (Object[]) getDebuggerFramesMethod.invoke( pci ); + // pageContext is actually a PageContextImpl, invoke method via reflection + Object[] nativeFrames = (Object[]) getDebuggerFramesMethod.invoke( pageContext ); - if ( nativeFrames == null || nativeFrames.length == 0 ) { - return null; - } + // Get suspend location - may contain exception info + var location = luceedebug.coreinject.NativeDebuggerListener.getSuspendLocation( threadId ); + Throwable exception = (location != null) ? location.exception : null; // Convert to IDebugFrame array, filtering frames with line 0 ArrayList result = new ArrayList<>(); - // Native frames are in push order (oldest first), DAP expects newest first - for ( int i = nativeFrames.length - 1; i >= 0; i-- ) { - Object nf = nativeFrames[i]; - int line = (int) getLineMethod.invoke( nf ); + if ( nativeFrames != null && nativeFrames.length > 0 ) { + // Native frames are in push order (oldest first), DAP expects newest first + for ( int i = nativeFrames.length - 1; i >= 0; i-- ) { + Object nf = nativeFrames[i]; + int line = (int) getLineMethod.invoke( nf ); + + // Skip frames with line 0 (not yet stepped into) + if ( line == 0 ) { + continue; + } - // Skip frames with line 0 (not yet stepped into) - if ( line == 0 ) { - continue; + // Only pass exception to the topmost frame (first one added to result) + Throwable frameException = result.isEmpty() ? exception : null; + result.add( new NativeDebugFrame( nf, pageContext, valTracker, i, frameException ) ); } + } - result.add( new NativeDebugFrame( nf, pageContext, valTracker, i ) ); + // If no frames from native stack, try to create synthetic frame from suspend location + if ( result.isEmpty() && threadId >= 0 ) { + Log.debug( "Checking suspend location for thread " + threadId + ": " + (location != null ? location.file + ":" + location.line : "null") ); + if ( location != null && location.file != null && location.line > 0 ) { + Log.debug( "Creating synthetic frame for top-level code: " + location.file + ":" + location.line + (location.label != null ? " label=" + location.label : "") ); + result.add( new NativeDebugFrame( pageContext, valTracker, location.file, location.line, location.label, exception ) ); + } } if ( result.isEmpty() ) { diff --git a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java index f68d8be..da98d89 100644 --- a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java +++ b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java @@ -2,12 +2,12 @@ import java.lang.reflect.Method; -import lucee.loader.engine.CFMLEngineFactory; import lucee.runtime.config.Config; import luceedebug.DapServer; +import luceedebug.EnvUtil; +import luceedebug.Log; import luceedebug.coreinject.NativeLuceeVm; -import luceedebug.coreinject.NativeDebuggerListener; /** * Extension startup hook - instantiated by Lucee when the extension loads. @@ -24,13 +24,13 @@ public class ExtensionActivator { * Lucee passes the Config object automatically. */ public ExtensionActivator(Config luceeConfig) { - System.out.println("[luceedebug] Extension activating via startup-hook"); + Log.info("Extension activating via startup-hook"); // Get debug port from environment - if not set, debugger is disabled - int debugPort = getDebuggerPort(); + int debugPort = EnvUtil.getDebuggerPort(); if (debugPort < 0) { - System.out.println("[luceedebug] Debugger not enabled"); - System.out.println("[luceedebug] Set LUCEE_DEBUGGER_PORT= to enable"); + Log.info("Debugger not enabled"); + Log.info("Set LUCEE_DEBUGGER_PORT= to enable"); return; } @@ -38,9 +38,16 @@ public ExtensionActivator(Config luceeConfig) { ClassLoader extensionLoader = this.getClass().getClassLoader(); ClassLoader luceeLoader = luceeConfig.getClass().getClassLoader(); + // Log execution logging status - this determines if breakpoints work + if (EnvUtil.isDebuggerEnabled()) { + Log.info("Execution logging: ENABLED (LUCEE_DEBUGGER_ENABLED=true)"); + } else { + Log.info("Execution logging: DISABLED (set LUCEE_DEBUGGER_ENABLED=true to enable breakpoints)"); + } + // Register debugger listener with Lucee's DebuggerRegistry if (!registerNativeDebuggerListener(luceeLoader, extensionLoader)) { - System.out.println("[luceedebug] Failed to register debugger listener - extension disabled"); + Log.error("Failed to register debugger listener - extension disabled"); return; } @@ -51,6 +58,9 @@ public ExtensionActivator(Config luceeConfig) { // Create luceedebug config luceedebug.Config config = new luceedebug.Config(fsCaseSensitive); + // Set Lucee classloader for reflection access to core classes + NativeLuceeVm.setLuceeClassLoader(luceeLoader); + // Create NativeLuceeVm luceeVm = new NativeLuceeVm(config); @@ -60,7 +70,87 @@ public ExtensionActivator(Config luceeConfig) { DapServer.createForSocket(luceeVm, config, "localhost", port); }, "luceedebug-dap-server").start(); - System.out.println("[luceedebug] DAP server starting on localhost:" + debugPort); + Log.info("DAP server starting on localhost:" + debugPort); + } + + /** + * Enable DebuggerExecutionLog via ConfigAdmin. + * This triggers template recompilation with exeLogStart()/exeLogEnd() bytecode + * which calls DebuggerRegistry.shouldSuspend() on each line. + * + * Note: During startup-hook, we receive ConfigServer (not ConfigWeb). + * We need to find a ConfigAdmin.newInstance() method that works with ConfigServer. + */ + private void enableDebuggerExecutionLog(Config luceeConfig, ClassLoader luceeLoader) { + try { + // Load ConfigAdmin class from Lucee core + Class configAdminClass = luceeLoader.loadClass("lucee.runtime.config.ConfigAdmin"); + Class classDefClass = luceeLoader.loadClass("lucee.runtime.db.ClassDefinition"); + Class classDefImplClass = luceeLoader.loadClass("lucee.transformer.library.ClassDefinitionImpl"); + Class structClass = luceeLoader.loadClass("lucee.runtime.type.Struct"); + Class structImplClass = luceeLoader.loadClass("lucee.runtime.type.StructImpl"); + + // Find the right newInstance method - try different signatures + Object configAdmin = null; + + // Try to find a method that accepts our config type + for (Method m : configAdminClass.getMethods()) { + if (m.getName().equals("newInstance") && m.getParameterCount() >= 2) { + Class[] params = m.getParameterTypes(); + // Look for (Config/ConfigServer, String/Password, boolean) or similar + if (params[0].isAssignableFrom(luceeConfig.getClass())) { + try { + if (params.length == 2) { + // (Config, Password) + configAdmin = m.invoke(null, luceeConfig, null); + } else if (params.length == 3 && params[2] == boolean.class) { + // (Config, Password, optionalPW) + configAdmin = m.invoke(null, luceeConfig, null, true); + } + if (configAdmin != null) { + Log.info("Created ConfigAdmin using " + m); + break; + } + } catch (Exception e) { + // Try next method + } + } + } + } + + if (configAdmin == null) { + Log.error("Could not create ConfigAdmin - no compatible newInstance method found"); + Log.info("Available newInstance methods:"); + for (Method m : configAdminClass.getMethods()) { + if (m.getName().equals("newInstance")) { + Log.info(" " + m); + } + } + return; + } + + // Create ClassDefinition for DebuggerExecutionLog + java.lang.reflect.Constructor cdConstructor = classDefImplClass.getConstructor(String.class); + Object classDefinition = cdConstructor.newInstance("lucee.runtime.engine.DebuggerExecutionLog"); + + // Create empty Struct for arguments + Object emptyStruct = structImplClass.getConstructor().newInstance(); + + // admin.updateExecutionLog(classDefinition, arguments, enabled=true) + Method updateMethod = configAdminClass.getMethod("updateExecutionLog", + classDefClass, structClass, boolean.class); + updateMethod.invoke(configAdmin, classDefinition, emptyStruct, true); + + // Persist and reload config - this triggers template recompilation + Method storeMethod = configAdminClass.getMethod("storeAndReload"); + storeMethod.invoke(configAdmin); + + Log.info("Enabled DebuggerExecutionLog - templates will recompile with debugger bytecode"); + } catch (ClassNotFoundException e) { + Log.error("ConfigAdmin not found - cannot enable execution log: " + e.getMessage()); + } catch (Throwable e) { + Log.error("Failed to enable execution log", e); + } } /** @@ -85,6 +175,9 @@ private boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoa final Method onResumeMethod = nativeListenerClass.getMethod("onResume", pageContextClass); final Method shouldSuspendMethod = nativeListenerClass.getMethod("shouldSuspend", pageContextClass, String.class, int.class); + final Method isDapClientConnectedMethod = nativeListenerClass.getMethod("isDapClientConnected"); + final Method onExceptionMethod = nativeListenerClass.getMethod("onException", + pageContextClass, Throwable.class, boolean.class); // Create proxy in Lucee's classloader, delegating to extension's implementation Object listenerProxy = java.lang.reflect.Proxy.newProxyInstance( @@ -92,10 +185,12 @@ private boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoa new Class[] { listenerInterface }, (proxy, method, args) -> { switch (method.getName()) { + case "isActive": return isDapClientConnectedMethod.invoke(null); case "onSuspend": return onSuspendMethod.invoke(null, args); case "onResume": return onResumeMethod.invoke(null, args); case "shouldSuspend": return shouldSuspendMethod.invoke(null, args); - default: throw new UnsupportedOperationException("Unknown method: " + method.getName()); + case "onException": return onExceptionMethod.invoke(null, args); + default: return null; // Default methods like onException have defaults } } ); @@ -104,43 +199,23 @@ private boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoa Method setListener = registryClass.getMethod("setListener", listenerInterface); setListener.invoke(null, listenerProxy); - System.out.println("[luceedebug] Registered native debugger listener"); + Log.info("Registered native debugger listener"); return true; } catch (ClassNotFoundException e) { - System.out.println("[luceedebug] DebuggerRegistry not found - requires Lucee 7.1+"); + Log.info("DebuggerRegistry not found - requires Lucee 7.1+"); return false; } catch (Throwable e) { - System.out.println("[luceedebug] Failed to register listener: " + e.getMessage()); - e.printStackTrace(); + Log.error("Failed to register listener", e); return false; } } /** - * Get debugger port from environment/system property. - * Returns -1 if not set (debugger disabled). + * Called by Lucee when the extension is uninstalled or updated. + * Shuts down the DAP server to free the port. */ - private static int getDebuggerPort() { - String port = getSystemPropOrEnvVar("lucee.debugger.port"); - if (port == null || port.isEmpty()) { - return -1; // disabled - } - return CFMLEngineFactory.getInstance().getCastUtil().toIntValue(port, -1); - } - - /** - * Get system property or environment variable. - * System property takes precedence. Env var name is derived from property name - * by uppercasing and replacing dots with underscores. - */ - private static String getSystemPropOrEnvVar(String propertyName) { - // Try system property first - String value = System.getProperty(propertyName); - if (value != null && !value.isEmpty()) { - return value; - } - // Try env var (lucee.debugger.port -> LUCEE_DEBUGGER_PORT) - String envName = propertyName.toUpperCase().replace('.', '_'); - return System.getenv(envName); + public void finalize() { + Log.info("Extension finalizing - shutting down DAP server"); + DapServer.shutdown(); } } diff --git a/profiling/DapClient.cfc b/profiling/DapClient.cfc index 962adcb..d80f719 100644 --- a/profiling/DapClient.cfc +++ b/profiling/DapClient.cfc @@ -35,14 +35,14 @@ component { variables.socket.setSoTimeout( 100 ); // 100ms read timeout for polling variables.inputStream = variables.socket.getInputStream(); variables.outputStream = variables.socket.getOutputStream(); - log( "Connected to #arguments.host#:#arguments.port#" ); + debugLog( "Connected to #arguments.host#:#arguments.port#" ); } public function disconnect() { if ( !isNull( variables.socket ) ) { variables.socket.close(); variables.socket = javacast( "null", 0 ); - log( "Disconnected" ); + debugLog( "Disconnected" ); } } @@ -195,14 +195,14 @@ component { private struct function sendRequest( required string command, required struct args ) { var requestSeq = ++variables.seq; - var request = { + var dapRequest = { "seq": requestSeq, "type": "request", "command": arguments.command, "arguments": arguments.args }; - sendMessage( request ); + sendMessage( dapRequest ); // Wait for response with matching request_seq var startTime = getTickCount(); @@ -231,9 +231,10 @@ component { private void function sendMessage( required struct message ) { var json = serializeJSON( arguments.message ); var bytes = json.getBytes( "UTF-8" ); - var header = "Content-Length: #bytes.length#\r\n\r\n"; + var CRLF = chr( 13 ) & chr( 10 ); + var header = "Content-Length: #arrayLen( bytes )#" & CRLF & CRLF; - log( ">>> #json#" ); + debugLog( ">>> #json#" ); variables.outputStream.write( header.getBytes( "UTF-8" ) ); variables.outputStream.write( bytes ); @@ -290,7 +291,7 @@ component { } var json = bodyBytes.toString( "UTF-8" ); - log( "<<< #json#" ); + debugLog( "<<< #json#" ); return deserializeJSON( json ); } @@ -325,11 +326,11 @@ component { variables.eventQueue.append( arguments.message ); break; default: - log( "Unknown message type: #arguments.message.type#" ); + debugLog( "Unknown message type: #arguments.message.type#" ); } } - private void function log( required string msg ) { + private void function debugLog( required string msg ) { if ( variables.debug ) { systemOutput( "[DapClient] #arguments.msg#", true ); } diff --git a/profiling/test-extension.bat b/profiling/test-extension.bat index 84672bf..274aa91 100644 --- a/profiling/test-extension.bat +++ b/profiling/test-extension.bat @@ -35,6 +35,7 @@ echo. rem Enable debugger via env var - set port to enable set LUCEE_DEBUGGER_PORT=10000 +set LUCEE_DEBUGGER_ENABLED=true rem Enable verbose logging set LUCEE_LOGGING_FORCE_APPENDER=console diff --git a/profiling/test-threads.bat b/profiling/test-threads.bat new file mode 100644 index 0000000..d8571cd --- /dev/null +++ b/profiling/test-threads.bat @@ -0,0 +1,30 @@ +@echo off +setlocal + +rem ============================================================ +rem Test DAP threads() command against running luceedebug server +rem ============================================================ + +set SCRIPT_RUNNER=D:\work\script-runner +set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling +set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.1.0.7-ALPHA.jar + +rem Check if Lucee JAR exists +if not exist "%LUCEE_JAR%" ( + echo ERROR: Lucee JAR not found at %LUCEE_JAR% + echo Run: cd /d/work/lucee7/loader ^&^& ant fast + exit /b 1 +) + +echo. +echo ============================================================ +echo Testing DAP Threads Command +echo ============================================================ +echo. +echo This test connects to an already-running luceedebug DAP server +echo Make sure your test Tomcat with the extension is running on port 10000 +echo. + +call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="test-threads.cfm" -DpostCleanup="false" -DpreCleanup="true" + +endlocal diff --git a/profiling/test-threads.cfm b/profiling/test-threads.cfm new file mode 100644 index 0000000..a065a6f --- /dev/null +++ b/profiling/test-threads.cfm @@ -0,0 +1,40 @@ + +// Test DAP threads() command +// Run this from a DIFFERENT Lucee instance than the one being debugged + +systemOutput( "=== DAP Threads Test ===", true ); + +dap = new DapClient( debug=true ); + +try { + dap.connect( "localhost", 10000 ); + + // Initialize + systemOutput( "Sending initialize...", true ); + result = dap.initialize(); + systemOutput( "Initialize response: " & serializeJSON( result ), true ); + + // Get threads + systemOutput( "Sending threads...", true ); + result = dap.threads(); + systemOutput( "Threads response: " & serializeJSON( result ), true ); + + if ( structKeyExists( result, "body" ) && structKeyExists( result.body, "threads" ) ) { + systemOutput( "Found #result.body.threads.len()# threads:", true ); + for ( var t in result.body.threads ) { + systemOutput( " - [#t.id#] #t.name#", true ); + } + } else { + systemOutput( "No threads in response!", true ); + } + + dap.dapDisconnect(); +} catch ( any e ) { + systemOutput( "ERROR: #e.message#", true ); + systemOutput( e.stackTrace, true ); +} finally { + dap.disconnect(); +} + +systemOutput( "=== Test Complete ===", true ); + From f8e9a457ac5a794ecb8cb7d268c9c6444218868c Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 5 Dec 2025 12:28:52 +0100 Subject: [PATCH 03/14] better console support --- luceedebug/build.gradle.kts | 2 +- .../src/main/java/luceedebug/Config.java | 47 ++- .../src/main/java/luceedebug/DapServer.java | 172 ++++++++-- .../src/main/java/luceedebug/EnvUtil.java | 55 ---- .../main/java/luceedebug/ExceptionUtil.java | 154 +++++++++ .../src/main/java/luceedebug/ILuceeVm.java | 14 + luceedebug/src/main/java/luceedebug/Log.java | 210 +++++++++++- .../java/luceedebug/PrefixPathTransform.java | 9 +- .../java/luceedebug/coreinject/LuceeVm.java | 10 + .../coreinject/NativeDebuggerListener.java | 308 +++++++++++++++--- .../luceedebug/coreinject/NativeLuceeVm.java | 21 +- .../coreinject/frame/NativeDebugFrame.java | 5 +- .../extension/ExtensionActivator.java | 7 +- vscode-client/package.json | 25 ++ 14 files changed, 877 insertions(+), 162 deletions(-) create mode 100644 luceedebug/src/main/java/luceedebug/ExceptionUtil.java diff --git a/luceedebug/build.gradle.kts b/luceedebug/build.gradle.kts index 30b4df5..8283dd1 100644 --- a/luceedebug/build.gradle.kts +++ b/luceedebug/build.gradle.kts @@ -96,7 +96,7 @@ tasks.jar { } } -val luceedebugVersion = "2.0.15" +val luceedebugVersion = "3.0.0" val libfile = "luceedebug-" + luceedebugVersion + ".jar" // TODO: this should, but does not currently, participate in the `clean` task, so the generated file sticks around after invoking `clean`. diff --git a/luceedebug/src/main/java/luceedebug/Config.java b/luceedebug/src/main/java/luceedebug/Config.java index e112fa8..c6e62ed 100644 --- a/luceedebug/src/main/java/luceedebug/Config.java +++ b/luceedebug/src/main/java/luceedebug/Config.java @@ -8,8 +8,23 @@ public class Config { // but for now it's configurable private boolean stepIntoUdfDefaultValueInitFrames_ = false; + /** + * Static cache of filesystem case sensitivity. + * Set once at startup when Config is instantiated. + * Used by canonicalizeFileName() to skip lowercase on case-sensitive filesystems. + */ + private static volatile boolean staticFsIsCaseSensitive = false; + + /** + * Base path prefix for shortening paths in log output. + * Set from pathTransforms when DAP client attaches. + */ + private static volatile String basePath = null; + public Config(boolean fsIsCaseSensitive) { this.fsIsCaseSensitive_ = fsIsCaseSensitive; + // Cache for static access + staticFsIsCaseSensitive = fsIsCaseSensitive; } public boolean getStepIntoUdfDefaultValueInitFrames() { @@ -51,7 +66,37 @@ public boolean getFsIsCaseSensitive() { } public static String canonicalizeFileName(String s) { - return s.replaceAll("[\\\\/]+", "/").toLowerCase(); + // Normalize slashes (always needed) + String normalized = s.replaceAll("[\\\\/]+", "/"); + // Only lowercase on case-insensitive filesystems (Windows) + return staticFsIsCaseSensitive ? normalized : normalized.toLowerCase(); + } + + /** + * Set the base path for shortening paths in log output. + */ + public static void setBasePath(String path) { + basePath = path != null ? canonicalizeFileName(path) : null; + } + + /** + * Shorten a path for display by removing the base path prefix. + * Returns the relative path (with leading /) if it starts with basePath, otherwise the full path. + */ + public static String shortenPath(String path) { + if (basePath == null || path == null) { + return path; + } + String canon = canonicalizeFileName(path); + if (canon.startsWith(basePath)) { + String relative = canon.substring(basePath.length()); + // Ensure leading slash + if (!relative.startsWith("/")) { + relative = "/" + relative; + } + return relative; + } + return path; } } diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index d162004..f4d663a 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -25,6 +25,7 @@ import org.eclipse.lsp4j.jsonrpc.util.ToStringBuilder; import luceedebug.coreinject.NativeDebuggerListener; +import luceedebug.generated.Constants; import luceedebug.strong.CanonicalServerAbsPath; import luceedebug.strong.RawIdePath; @@ -133,7 +134,23 @@ private DapServer(ILuceeVm luceeVm, Config config) { event.setDescription(label); } clientProxy_.stopped(event); - Log.info("Sent DAP stopped event for native breakpoint, thread=" + javaThreadId + (label != null ? " label=" + label : "")); + Log.debug("Stopped event sent: thread=" + javaThreadId + (label != null ? " label=" + label : "")); + }); + + // Register native exception callback (Lucee7+ uncaught exception) + this.luceeVm_.registerExceptionEventCallback(javaThreadId -> { + final int i32_threadID = (int)(long)javaThreadId; + var event = new StoppedEventArguments(); + event.setReason("exception"); + event.setThreadId(i32_threadID); + // Get exception details for the description + Throwable ex = luceeVm_.getExceptionForThread(javaThreadId); + if (ex != null) { + event.setDescription(ex.getClass().getSimpleName() + ": " + ex.getMessage()); + event.setText(ex.getMessage()); + } + clientProxy_.stopped(event); + Log.debug("Sent DAP stopped event for exception, thread=" + javaThreadId + (ex != null ? " exception=" + ex.getClass().getName() : "")); }); } @@ -198,28 +215,9 @@ static public DapEntry createForSocket(ILuceeVm luceeVm, Config config, String h try { Log.info("Creating DAP entry..."); - // Wrap streams with logging var rawIn = socket.getInputStream(); var rawOut = socket.getOutputStream(); - var loggingIn = new java.io.FilterInputStream(rawIn) { - @Override - public int read() throws java.io.IOException { - int b = super.read(); - if (b >= 0) { - System.out.print((char)b); - } - return b; - } - @Override - public int read(byte[] b, int off, int len) throws java.io.IOException { - int n = super.read(b, off, len); - if (n > 0) { - System.out.print("[IN:" + n + "]" + new String(b, off, n, "UTF-8")); - } - return n; - } - }; - var dapEntry = create(luceeVm, config, loggingIn, rawOut); + var dapEntry = create(luceeVm, config, rawIn, rawOut); Log.info("DAP launcher created, starting listening..."); // Enable DAP output for this client Log.setDapClient(dapEntry.server.clientProxy_); @@ -272,16 +270,16 @@ public static void shutdown() { Object storedSocket = System.getProperties().get(SOCKET_PROPERTY); if (storedSocket instanceof ServerSocket) { ServerSocket socket = (ServerSocket) storedSocket; - Log.info("shutdown() - found socket in system properties, closing..."); + Log.debug("shutdown() - found socket in system properties, closing..."); try { socket.close(); - Log.info("shutdown() - socket closed"); + Log.debug("shutdown() - socket closed"); } catch (Exception e) { Log.error("shutdown() - socket close error", e); } System.getProperties().remove(SOCKET_PROPERTY); } else { - Log.info("shutdown() - no socket in system properties"); + Log.debug("shutdown() - no socket in system properties"); } // Also close our local static reference if set @@ -302,8 +300,7 @@ static public DapEntry create(ILuceeVm luceeVm, Config config, InputStream in, O @Override public CompletableFuture initialize(InitializeRequestArguments args) { - Log.info("initialize() called with args: " + args); - System.out.println("[luceedebug] INITIALIZE CALLED - this should appear in Tomcat logs"); + Log.debug("initialize() called with args: " + args); var c = new Capabilities(); c.setSupportsEvaluateForHovers(true); c.setSupportsConfigurationDoneRequest(true); @@ -319,7 +316,8 @@ public CompletableFuture initialize(InitializeRequestArguments arg uncaughtFilter.setLabel("Uncaught Exceptions"); uncaughtFilter.setDescription("Break when an exception is not caught by a try/catch block"); c.setExceptionBreakpointFilters(new ExceptionBreakpointsFilter[] { uncaughtFilter }); - Log.info("Returning capabilities with exceptionBreakpointFilters: " + java.util.Arrays.toString(c.getExceptionBreakpointFilters())); + c.setSupportsExceptionInfoRequest(true); + Log.debug("Returning capabilities with exceptionBreakpointFilters: " + java.util.Arrays.toString(c.getExceptionBreakpointFilters())); return CompletableFuture.completedFuture(c); } @@ -384,25 +382,70 @@ private boolean getAsBool(Object obj, boolean defaultValue) { @Override public CompletableFuture attach(Map args) { + // Configure logging from launch.json (before other logging) + configureLogging(args); + pathTransforms = tryMungePathTransforms(args.get("pathTransforms")); config_.setStepIntoUdfDefaultValueInitFrames(getBoolOrFalseIfNonBool(args.get("stepIntoUdfDefaultValueInitFrames"))); clientProxy_.initialized(); + // Log version info + String luceeVersion = getLuceeVersion(); + Log.info("luceedebug " + Constants.version + " connected to Lucee " + luceeVersion); + if (pathTransforms.size() == 0) { - Log.info("DAP client attached - no path transforms configured"); + Log.info("No path transforms configured"); } else { - Log.info("DAP client attached - path transforms:"); for (var transform : pathTransforms) { - Log.info(" " + transform.asTraceString()); + Log.info(transform.asTraceString()); + // Set base path for shortening paths in logs (use first transform's server prefix) + if (transform instanceof PrefixPathTransform) { + Config.setBasePath(((PrefixPathTransform)transform).getServerPrefix()); + } } } return CompletableFuture.completedFuture(null); } + /** + * Configure logging from launch.json settings. + * Supports: logColor (boolean), logLevel (error|info|debug), logExceptions (boolean), logSystemOutput (boolean) + */ + private void configureLogging(Map args) { + lucee.runtime.util.Cast caster = lucee.loader.engine.CFMLEngineFactory.getInstance().getCastUtil(); + + // logColor - default true + Log.setColorLogs(caster.toBooleanValue(args.get("logColor"), true)); + + // logLevel - error, info, debug + Object logLevel = args.get("logLevel"); + if (logLevel instanceof String) { + String level = ((String) logLevel).toLowerCase(); + switch (level) { + case "error": + Log.setLogLevel(Log.LogLevel.ERROR); + break; + case "debug": + Log.setLogLevel(Log.LogLevel.DEBUG); + break; + case "info": + default: + Log.setLogLevel(Log.LogLevel.INFO); + break; + } + } + + // logExceptions - default false + Log.setLogExceptions(caster.toBooleanValue(args.get("logExceptions"), false)); + + // logSystemOutput - default false + NativeDebuggerListener.setLogSystemOutput(caster.toBooleanValue(args.get("logSystemOutput"), false)); + } + static final Pattern threadNamePrefixAndDigitSuffix = Pattern.compile("^(.+?)(\\d+)$"); @Override @@ -455,7 +498,7 @@ public CompletableFuture stackTrace(StackTraceArguments args final var source = new Source(); String rawPath = cfFrame.getSourceFilePath(); String transformedPath = applyPathTransformsServerToIde(rawPath); - Log.info("stackTrace: raw=" + rawPath + " -> transformed=" + transformedPath); + Log.debug("stackTrace: raw=" + rawPath + " -> transformed=" + transformedPath); source.setPath(transformedPath); final var lspFrame = new org.eclipse.lsp4j.debug.StackFrame(); @@ -566,7 +609,7 @@ private Breakpoint map_cfBreakpoint_to_lsp4jBreakpoint(IBreakpoint cfBreakpoint) public CompletableFuture setExceptionBreakpoints(SetExceptionBreakpointsArguments args) { // Check if "uncaught" is in the filters String[] filters = args.getFilters(); - Log.info("setExceptionBreakpoints: filters=" + java.util.Arrays.toString(filters)); + Log.debug("setExceptionBreakpoints: filters=" + java.util.Arrays.toString(filters)); boolean breakOnUncaught = false; if (filters != null) { for (String filter : filters) { @@ -577,13 +620,54 @@ public CompletableFuture setExceptionBreakpoint } } NativeDebuggerListener.setBreakOnUncaughtExceptions(breakOnUncaught); - Log.info("setExceptionBreakpoints: uncaught=" + breakOnUncaught); return CompletableFuture.completedFuture(new SetExceptionBreakpointsResponse()); } + /** + * Returns exception details when stopped due to an exception. + * VSCode calls this after receiving a stopped event with reason="exception". + */ + @Override + public CompletableFuture exceptionInfo(ExceptionInfoArguments args) { + Log.debug("exceptionInfo() called for thread " + args.getThreadId()); + Throwable ex = luceeVm_.getExceptionForThread(args.getThreadId()); + var response = new ExceptionInfoResponse(); + if (ex != null) { + response.setExceptionId(ex.getClass().getName()); + response.setDescription(ex.getMessage()); + response.setBreakMode(ExceptionBreakMode.UNHANDLED); + // Build detailed stack trace + var details = new ExceptionDetails(); + // Include detail if available (Lucee PageException has getDetail()) + String message = ex.getMessage(); + String detail = ExceptionUtil.getDetail(ex); + if (detail != null && !detail.isEmpty()) { + message = message + "\n\nDetail: " + detail; + } + details.setMessage(message); + details.setTypeName(ex.getClass().getName()); + details.setStackTrace(ExceptionUtil.getCfmlStackTraceOrFallback(ex)); + // Include inner exception if present + if (ex.getCause() != null) { + var inner = new ExceptionDetails(); + inner.setMessage(ex.getCause().getMessage()); + inner.setTypeName(ex.getCause().getClass().getName()); + details.setInnerException(new ExceptionDetails[] { inner }); + } + response.setDetails(details); + Log.debug("exceptionInfo() returning: " + ex.getClass().getName() + " - " + ex.getMessage()); + } else { + response.setExceptionId("unknown"); + response.setDescription("No exception information available"); + response.setBreakMode(ExceptionBreakMode.UNHANDLED); + Log.debug("exceptionInfo() - no exception found for thread"); + } + return CompletableFuture.completedFuture(response); + } + /** * Can we disable the UI for this in the client plugin? - * + * * @unsupported */ public CompletableFuture pause(PauseArguments args) { @@ -905,7 +989,9 @@ CompletableFuture getSourcePath(GetSourcePathArguments ar static private AtomicLong anonymousID = new AtomicLong(); public CompletableFuture evaluate(EvaluateArguments args) { + Log.debug("evaluate() called: expression=" + args.getExpression() + ", context=" + args.getContext() + ", frameId=" + args.getFrameId()); if (args.getFrameId() == null) { + Log.info("evaluate() - frameId is null, returning error"); final var exceptionalResult = new CompletableFuture(); final var error = new ResponseError(ResponseErrorCode.InvalidRequest, "missing frameID", null); exceptionalResult.completeExceptionally(new ResponseErrorException(error)); @@ -916,6 +1002,7 @@ public CompletableFuture evaluate(EvaluateArguments args) { .evaluate(args.getFrameId(), args.getExpression()) .collapse( errMsg -> { + Log.info("evaluate() - error: " + errMsg); final var exceptionalResult = new CompletableFuture(); final var error = new ResponseError(ResponseErrorCode.InternalError, errMsg, null); exceptionalResult.completeExceptionally(new ResponseErrorException(error)); @@ -928,12 +1015,14 @@ public CompletableFuture evaluate(EvaluateArguments args) { final var response = new EvaluateResponse(); if (value == null) { // some problem, or we tried to get a function from a cfc maybe? this needs work. + Log.info("evaluate() - success (object) but value is null, returning ???"); response.setVariablesReference(0); response.setIndexedVariables(0); response.setNamedVariables(0); response.setResult("???"); } else { + Log.info("evaluate() - success (object): " + value.getValue()); response.setVariablesReference((int)(long)value.getVariablesReference()); response.setIndexedVariables(value.getIndexedVariables()); response.setNamedVariables(value.getNamedVariables()); @@ -943,6 +1032,7 @@ public CompletableFuture evaluate(EvaluateArguments args) { return CompletableFuture.completedFuture(response); }, string -> { + Log.info("evaluate() - success (string): " + string); final var response = new EvaluateResponse(); response.setResult(string); return CompletableFuture.completedFuture(response); @@ -950,5 +1040,17 @@ public CompletableFuture evaluate(EvaluateArguments args) { } ); } - } + } + + /** + * Get the Lucee version string (e.g., "7.0.1.7-ALPHA"). + */ + private static String getLuceeVersion() { + try { + lucee.Info info = lucee.loader.engine.CFMLEngineFactory.getInstance().getInfo(); + return info.getVersion().toString(); + } catch (Exception e) { + return "unknown"; + } + } } diff --git a/luceedebug/src/main/java/luceedebug/EnvUtil.java b/luceedebug/src/main/java/luceedebug/EnvUtil.java index ec900af..aba59ae 100644 --- a/luceedebug/src/main/java/luceedebug/EnvUtil.java +++ b/luceedebug/src/main/java/luceedebug/EnvUtil.java @@ -55,59 +55,4 @@ public static int getDebuggerPort() { return -1; } } - - /** - * Log level for debugger output. - */ - public enum LogLevel { - ERROR(0), - INFO(1), - DEBUG(2); - - private final int level; - - LogLevel(int level) { - this.level = level; - } - - public boolean isEnabled(LogLevel threshold) { - return this.level <= threshold.level; - } - } - - // Cached log level - read once from env - private static LogLevel cachedLogLevel = null; - - /** - * Get log level from environment/system property. - * Checks "lucee.debugger.loglevel" / "LUCEE_DEBUGGER_LOGLEVEL". - * Valid values: error, info, debug (case-insensitive) - * Default: info - * - * @return the log level - */ - public static LogLevel getLogLevel() { - if (cachedLogLevel != null) { - return cachedLogLevel; - } - String level = getSystemPropOrEnvVar("lucee.debugger.loglevel"); - if (level == null || level.isEmpty()) { - cachedLogLevel = LogLevel.INFO; - } else if ("debug".equalsIgnoreCase(level)) { - cachedLogLevel = LogLevel.DEBUG; - } else if ("error".equalsIgnoreCase(level)) { - cachedLogLevel = LogLevel.ERROR; - } else { - cachedLogLevel = LogLevel.INFO; - } - return cachedLogLevel; - } - - /** - * Check if debug logging is enabled. - * @return true if log level is DEBUG - */ - public static boolean isDebugLoggingEnabled() { - return getLogLevel() == LogLevel.DEBUG; - } } diff --git a/luceedebug/src/main/java/luceedebug/ExceptionUtil.java b/luceedebug/src/main/java/luceedebug/ExceptionUtil.java new file mode 100644 index 0000000..26bf807 --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/ExceptionUtil.java @@ -0,0 +1,154 @@ +package luceedebug; + +/** + * Utility class for extracting information from Lucee exceptions. + * Uses reflection to handle OSGi classloader isolation. + */ +public final class ExceptionUtil { + + private ExceptionUtil() {} + + /** + * Get the first CFML location from an exception's stack trace. + * @return "template:line" or null if no CFML frame found + */ + public static String getFirstCfmlLocation(Throwable ex) { + // First try tagContext (more accurate for Lucee PageExceptions) + String tagContextLocation = getFirstTagContextLocation(ex); + if (tagContextLocation != null) { + return tagContextLocation; + } + // Fallback to Java stack trace + for (StackTraceElement ste : ex.getStackTrace()) { + if (ste.getClassName().endsWith("$cf")) { + return ste.getFileName() + ":" + ste.getLineNumber(); + } + } + return null; + } + + /** + * Get the full CFML stack trace from a PageException's tagContext. + * @return Multi-line string of "template:line" entries, or null if not available + */ + public static String getCfmlStackTrace(Throwable ex) { + try { + // Get Config via reflection (OSGi classloader isolation) + ClassLoader loader = ex.getClass().getClassLoader(); + Class tlpcClass = loader.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method getConfig = tlpcClass.getMethod("getConfig"); + Object config = getConfig.invoke(null); + if (config == null) { + return null; + } + // Check if it's a PageException with getTagContext(Config) + Class configClass = loader.loadClass("lucee.runtime.config.Config"); + java.lang.reflect.Method getTagContext = ex.getClass().getMethod("getTagContext", configClass); + Object tagContext = getTagContext.invoke(ex, config); + if (tagContext == null) { + return null; + } + // tagContext is a lucee.runtime.type.Array + StringBuilder sb = new StringBuilder(); + java.lang.reflect.Method size = tagContext.getClass().getMethod("size"); + java.lang.reflect.Method getE = tagContext.getClass().getMethod("getE", int.class); + int len = (Integer) size.invoke(tagContext); + + // Get KeyImpl.init for creating keys + Class keyImplClass = loader.loadClass("lucee.runtime.type.KeyImpl"); + java.lang.reflect.Method keyInit = keyImplClass.getMethod("init", String.class); + Object templateKey = keyInit.invoke(null, "template"); + Object lineKey = keyInit.invoke(null, "line"); + + // Get the Struct.get(Key, defaultValue) method + Class keyClass = loader.loadClass("lucee.runtime.type.Collection$Key"); + + for (int i = 1; i <= len; i++) { + Object item = getE.invoke(tagContext, i); + // item is a Struct with template, line, codePrintPlain + java.lang.reflect.Method get = item.getClass().getMethod("get", keyClass, Object.class); + String template = (String) get.invoke(item, templateKey, ""); + Object lineObj = get.invoke(item, lineKey, 0); + int line = lineObj instanceof Number ? ((Number) lineObj).intValue() : 0; + sb.append(template).append(":").append(line).append("\n"); + } + return sb.toString(); + } catch (Exception e) { + Log.debug("getCfmlStackTrace failed: " + e.getMessage()); + return null; + } + } + + /** + * Get the first location from tagContext. + */ + private static String getFirstTagContextLocation(Throwable ex) { + try { + ClassLoader loader = ex.getClass().getClassLoader(); + Class tlpcClass = loader.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method getConfig = tlpcClass.getMethod("getConfig"); + Object config = getConfig.invoke(null); + if (config == null) { + return null; + } + Class configClass = loader.loadClass("lucee.runtime.config.Config"); + java.lang.reflect.Method getTagContext = ex.getClass().getMethod("getTagContext", configClass); + Object tagContext = getTagContext.invoke(ex, config); + if (tagContext == null) { + return null; + } + java.lang.reflect.Method size = tagContext.getClass().getMethod("size"); + int len = (Integer) size.invoke(tagContext); + if (len == 0) { + return null; + } + java.lang.reflect.Method getE = tagContext.getClass().getMethod("getE", int.class); + Object item = getE.invoke(tagContext, 1); + + // Create keys for struct access + Class keyImplClass = loader.loadClass("lucee.runtime.type.KeyImpl"); + java.lang.reflect.Method keyInit = keyImplClass.getMethod("init", String.class); + Object templateKey = keyInit.invoke(null, "template"); + Object lineKey = keyInit.invoke(null, "line"); + Class keyClass = loader.loadClass("lucee.runtime.type.Collection$Key"); + + java.lang.reflect.Method get = item.getClass().getMethod("get", keyClass, Object.class); + String template = (String) get.invoke(item, templateKey, ""); + Object lineObj = get.invoke(item, lineKey, 0); + int line = lineObj instanceof Number ? ((Number) lineObj).intValue() : 0; + return template + ":" + line; + } catch (Exception e) { + return null; + } + } + + /** + * Get the CFML stack trace, falling back to filtered Java stack trace. + */ + public static String getCfmlStackTraceOrFallback(Throwable ex) { + String stackTrace = getCfmlStackTrace(ex); + if (stackTrace != null && !stackTrace.isEmpty()) { + return stackTrace; + } + // Fallback to Java stack trace filtered to CFML frames + StringBuilder sb = new StringBuilder(); + for (StackTraceElement ste : ex.getStackTrace()) { + if (ste.getClassName().endsWith("$cf")) { + sb.append(ste.getFileName()).append(":").append(ste.getLineNumber()).append("\n"); + } + } + return sb.toString(); + } + + /** + * Get exception detail from PageException.getDetail() via reflection. + */ + public static String getDetail(Throwable ex) { + try { + java.lang.reflect.Method getDetail = ex.getClass().getMethod("getDetail"); + return (String) getDetail.invoke(ex); + } catch (Exception e) { + return null; + } + } +} diff --git a/luceedebug/src/main/java/luceedebug/ILuceeVm.java b/luceedebug/src/main/java/luceedebug/ILuceeVm.java index ed6c295..f0a0aab 100644 --- a/luceedebug/src/main/java/luceedebug/ILuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/ILuceeVm.java @@ -85,4 +85,18 @@ public static BreakpointsChangedEvent justChanges(IBreakpoint[] changes) { public String getSourcePathForVariablesRef(int variablesRef); public Either> evaluate(int frameID, String expr); + + /** + * Register callback for exception events (native mode only). + * Called with Java thread ID when a thread stops due to an uncaught exception. + */ + public void registerExceptionEventCallback(java.util.function.Consumer cb); + + /** + * Get the exception that caused a thread to suspend. + * Returns null if the thread is not suspended due to an exception. + * @param threadId The Java thread ID + * @return The exception, or null + */ + public Throwable getExceptionForThread(long threadId); } diff --git a/luceedebug/src/main/java/luceedebug/Log.java b/luceedebug/src/main/java/luceedebug/Log.java index 1b76c81..e091378 100644 --- a/luceedebug/src/main/java/luceedebug/Log.java +++ b/luceedebug/src/main/java/luceedebug/Log.java @@ -9,13 +9,52 @@ * Routes all log messages through a common method that: * - Writes to System.out with [luceedebug] prefix * - Optionally sends to DAP OutputEvent when a client is connected + * - Supports ANSI colors (configurable via launch.json colorLogs, default true) + * - Respects log level (configurable via launch.json logLevel, default info) */ public class Log { private static final String PREFIX = "[luceedebug] "; + // ANSI escape codes (for console/tomcat output) + private static final String ANSI_RESET = "\u001b[0m"; + private static final String ANSI_RED = "\u001b[31m"; + private static final String ANSI_YELLOW = "\u001b[33m"; + private static final String ANSI_CYAN = "\u001b[36m"; + private static final String ANSI_DIM = "\u001b[2m"; + // DAP client for sending OutputEvents (set when client connects) private static volatile IDebugProtocolClient dapClient = null; + // Runtime settings from launch.json + private static volatile boolean colorLogs = true; + private static volatile LogLevel logLevel = LogLevel.INFO; + private static volatile boolean logExceptions = false; + private static volatile boolean logSystemOutput = false; + + // Internal debugging - only enabled via env var LUCEE_DEBUGGER_DEBUG + private static final boolean internalDebug; + static { + String env = System.getenv("LUCEE_DEBUGGER_DEBUG"); + internalDebug = env != null && !env.isEmpty() && !env.equals("0") && !env.equalsIgnoreCase("false"); + } + + public enum LogLevel { + ERROR(0), + INFO(1), + DEBUG(2), + TRACE(3); + + private final int level; + + LogLevel(int level) { + this.level = level; + } + + public boolean isEnabled(LogLevel threshold) { + return this.level <= threshold.level; + } + } + /** * Set the DAP client to receive log messages as OutputEvents. * Pass null to disable DAP output (e.g., on disconnect). @@ -25,20 +64,70 @@ public static void setDapClient(IDebugProtocolClient client) { } /** - * Log a message to console and optionally to DAP client. + * Set color logs setting from launch.json. + */ + public static void setColorLogs(boolean enabled) { + colorLogs = enabled; + } + + /** + * Set log level from launch.json. + */ + public static void setLogLevel(LogLevel level) { + logLevel = level; + } + + /** + * Set exception logging from launch.json. + */ + public static void setLogExceptions(boolean enabled) { + logExceptions = enabled; + } + + /** + * Set system output logging from launch.json. + * When enabled, we skip sending directly to DAP since System.out/err + * will be captured and forwarded via systemOutput(). + */ + public static void setLogSystemOutput(boolean enabled) { + logSystemOutput = enabled; + } + + /** + * Log an info message to console and optionally to DAP client. + * Only logged if log level is INFO or higher. */ public static void info(String message) { - String prefixed = PREFIX + message; - System.out.println(prefixed); + if (!LogLevel.INFO.isEnabled(logLevel)) { + return; + } + // When logSystemOutput is enabled, skip System.out (it gets captured and + // forwarded to DAP, causing double-logging). Send directly to DAP instead. + if (!logSystemOutput) { + String consoleMsg; + if (colorLogs) { + consoleMsg = ANSI_CYAN + PREFIX + ANSI_RESET + message; + } else { + consoleMsg = PREFIX + message; + } + System.out.println(consoleMsg); + } sendToDap(message, OutputEventArgumentsCategory.CONSOLE); } /** - * Log an error message. + * Log an error message. Always logged regardless of log level. */ public static void error(String message) { - String prefixed = PREFIX + "ERROR: " + message; - System.out.println(prefixed); + if (!logSystemOutput) { + String consoleMsg; + if (colorLogs) { + consoleMsg = ANSI_RED + PREFIX + "ERROR: " + message + ANSI_RESET; + } else { + consoleMsg = PREFIX + "ERROR: " + message; + } + System.out.println(consoleMsg); + } sendToDap("ERROR: " + message, OutputEventArgumentsCategory.STDERR); } @@ -51,12 +140,113 @@ public static void error(String message, Throwable t) { } /** - * Log a debug message (only to console, not to DAP). - * Only printed if LUCEE_DEBUGGER_LOGLEVEL=debug. + * Log a debug message. + * Only printed if LUCEE_DEBUGGER_DEBUG env var is set. + * Uses STDOUT category in DAP for normal (non-highlighted) display. */ public static void debug(String message) { - if (EnvUtil.isDebugLoggingEnabled()) { - System.out.println(PREFIX + "DEBUG: " + message); + if (!internalDebug) { + return; + } + if (!logSystemOutput) { + String consoleMsg; + if (colorLogs) { + consoleMsg = ANSI_DIM + PREFIX + "DEBUG: " + message + ANSI_RESET; + } else { + consoleMsg = PREFIX + "DEBUG: " + message; + } + System.out.println(consoleMsg); + } + sendToDap("DEBUG: " + message, OutputEventArgumentsCategory.STDOUT); + } + + /** + * Log a trace message. + * Only printed if LUCEE_DEBUGGER_DEBUG env var is set. + * Uses STDOUT category in DAP for normal (non-highlighted) display. + */ + public static void trace(String message) { + if (!internalDebug) { + return; + } + if (!logSystemOutput) { + String consoleMsg; + if (colorLogs) { + consoleMsg = ANSI_DIM + PREFIX + "TRACE: " + message + ANSI_RESET; + } else { + consoleMsg = PREFIX + "TRACE: " + message; + } + System.out.println(consoleMsg); + } + sendToDap("TRACE: " + message, OutputEventArgumentsCategory.STDOUT); + } + + /** + * Log a warning message. + * Only logged if log level is INFO or higher. + * Uses IMPORTANT category in DAP for highlighted display. + */ + public static void warn(String message) { + if (!LogLevel.INFO.isEnabled(logLevel)) { + return; + } + if (!logSystemOutput) { + String consoleMsg; + if (colorLogs) { + consoleMsg = ANSI_YELLOW + PREFIX + "WARN: " + message + ANSI_RESET; + } else { + consoleMsg = PREFIX + "WARN: " + message; + } + System.out.println(consoleMsg); + } + sendToDap("WARN: " + message, OutputEventArgumentsCategory.IMPORTANT); + } + + /** + * Log an exception to the debug console (if logExceptions is enabled). + * Only sends to DAP, not to System.out. + */ + public static void exception(Throwable t) { + if (!logExceptions) { + return; + } + StringBuilder sb = new StringBuilder(); + sb.append(t.getClass().getSimpleName()).append(": ").append(t.getMessage()); + + // Get full CFML stack trace + String stackTrace = ExceptionUtil.getCfmlStackTraceOrFallback(t); + if (stackTrace != null && !stackTrace.isEmpty()) { + for (String line : stackTrace.split("\n")) { + if (!line.isEmpty()) { + sb.append("\n at ").append(line); + } + } + } else { + sb.append("\n at unknown"); + } + sendToDap(sb.toString(), OutputEventArgumentsCategory.STDERR); + } + + /** + * Forward System.out/err output to DAP client. + * Called by NativeDebuggerListener.onOutput() when logSystemOutput is enabled. + * Does NOT echo to console (would cause infinite loop). + * + * @param text The text that was written + * @param isStdErr true if stderr, false if stdout + */ + public static void systemOutput(String text, boolean isStdErr) { + IDebugProtocolClient client = dapClient; + if (client != null) { + try { + var args = new OutputEventArguments(); + args.setCategory(isStdErr ? OutputEventArgumentsCategory.STDERR : OutputEventArgumentsCategory.STDOUT); + // Pass through as-is - the source already includes newlines + args.setOutput(text); + client.output(args); + } catch (Exception e) { + // Silently ignore - can't log here or we'd recurse + } } } diff --git a/luceedebug/src/main/java/luceedebug/PrefixPathTransform.java b/luceedebug/src/main/java/luceedebug/PrefixPathTransform.java index 1f861e7..3b15742 100644 --- a/luceedebug/src/main/java/luceedebug/PrefixPathTransform.java +++ b/luceedebug/src/main/java/luceedebug/PrefixPathTransform.java @@ -28,8 +28,15 @@ public Optional ideToServer(String s) { return replacePrefix(s, caseAndPathSepLenient_idePrefixPattern, unadjusted_serverPrefix); } + public String getServerPrefix() { + return unadjusted_serverPrefix; + } + public String asTraceString() { - return "PrefixPathTransform{idePrefix='" + unadjusted_idePrefix + "', serverPrefix='" + unadjusted_serverPrefix + "'}"; + if (unadjusted_idePrefix.equals(unadjusted_serverPrefix)) { + return "Path: " + unadjusted_idePrefix; + } + return "Path mapping: IDE='" + unadjusted_idePrefix + "' -> Server='" + unadjusted_serverPrefix + "'"; } /** diff --git a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java index 2360f97..bf7e5a7 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java @@ -1179,4 +1179,14 @@ public String getSourcePathForVariablesRef(int variablesRef) { public Either> evaluate(int frameID, String expr) { return GlobalIDebugManagerHolder.debugManager.evaluate((Long)(long)frameID, expr); } + + // Not used in JDWP mode - exception handling uses JDWP events + public void registerExceptionEventCallback(Consumer cb) { + // no-op for JDWP mode + } + + public Throwable getExceptionForThread(long threadId) { + // JDWP mode doesn't use NativeDebuggerListener for exceptions + return null; + } } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java index 0bfed5f..b6469ba 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java @@ -34,16 +34,22 @@ enum StepMode { public class NativeDebuggerListener { /** - * Map of "file:line" -> true for active breakpoints. - * Uses ConcurrentHashMap for thread-safe access from multiple request threads. + * Breakpoint storage - parallel arrays for fast lookup. + * Writers synchronize on breakpointLock, readers just access the arrays. + * The volatile hasSuspendConditions provides the memory barrier for visibility. */ - private static final ConcurrentHashMap breakpoints = new ConcurrentHashMap<>(); + private static final Object breakpointLock = new Object(); + private static int[] bpLines = new int[0]; + private static String[] bpFiles = new String[0]; + private static String[] bpConditions = new String[0]; // null entries for unconditional /** - * Map of "file:line" -> condition expression for conditional breakpoints. - * If a breakpoint has no condition, it won't have an entry here. + * Pre-computed bounds for fast rejection in shouldSuspend(). + * Updated whenever breakpoints change. */ - private static final ConcurrentHashMap breakpointConditions = new ConcurrentHashMap<>(); + private static int bpMinLine = Integer.MAX_VALUE; + private static int bpMaxLine = Integer.MIN_VALUE; + private static int bpMaxPathLen = 0; /** * Map of Java thread ID -> WeakReference for natively suspended threads. @@ -96,6 +102,12 @@ public SuspendLocation( String file, int line, String label, Throwable exception */ private static volatile Consumer onNativeStepCallback = null; + /** + * Callback to notify LuceeVm when a thread stops due to an exception. + * Called with Java thread ID. Used for "exception" stop reason in DAP. + */ + private static volatile Consumer onNativeExceptionCallback = null; + /** * Flag to indicate native-only mode (no JDWP breakpoints). * When true, only native breakpoints are used. @@ -115,11 +127,51 @@ public SuspendLocation( String file, int line, String label, Throwable exception */ private static volatile boolean breakOnUncaughtExceptions = false; + /** + * Flag to forward System.out/err to DAP client. + * Set via launch.json logSystemOutput option. + */ + private static volatile boolean logSystemOutput = false; + + /** + * Fast-path flag: true when there's anything that could cause a suspend. + * When false, shouldSuspend() returns immediately without any work. + * Updated whenever breakpoints, exceptions, or stepping state changes. + */ + private static volatile boolean hasSuspendConditions = false; + /** * Per-thread stepping state. */ private static final ConcurrentHashMap steppingThreads = new ConcurrentHashMap<>(); + /** + * Update hasSuspendConditions flag based on current state. + * Called whenever breakpoints, exception settings, or stepping state changes. + */ + private static void updateHasSuspendConditions() { + hasSuspendConditions = dapClientConnected && + (bpLines.length > 0 || breakOnUncaughtExceptions || !steppingThreads.isEmpty()); + } + + /** + * Rebuild breakpoint bounds after any modification. + * Must be called while holding breakpointLock. + */ + private static void rebuildBreakpointBounds() { + int min = Integer.MAX_VALUE; + int max = Integer.MIN_VALUE; + int maxLen = 0; + for (int i = 0; i < bpLines.length; i++) { + if (bpLines[i] < min) min = bpLines[i]; + if (bpLines[i] > max) max = bpLines[i]; + if (bpFiles[i].length() > maxLen) maxLen = bpFiles[i].length(); + } + bpMinLine = min; + bpMaxLine = max; + bpMaxPathLen = maxLen; + } + /** * Stepping state for a single thread. */ @@ -164,6 +216,14 @@ public static void setOnNativeStepCallback(Consumer callback) { onNativeStepCallback = callback; } + /** + * Set the callback for native exception events. + * LuceeVm should register this to receive notifications and send DAP exception events. + */ + public static void setOnNativeExceptionCallback(Consumer callback) { + onNativeExceptionCallback = callback; + } + /** * Add a breakpoint at the given file and line. */ @@ -176,51 +236,130 @@ public static void addBreakpoint(String file, int line) { * @param condition CFML expression to evaluate, or null for unconditional breakpoint */ public static void addBreakpoint(String file, int line, String condition) { - String key = makeKey(file, line); - breakpoints.put(key, Boolean.TRUE); - if (condition != null && !condition.isEmpty()) { - breakpointConditions.put(key, condition); - Log.info("Added native breakpoint: " + key + " condition=" + condition); - } else { - breakpointConditions.remove(key); // ensure no stale condition - Log.info("Added native breakpoint: " + key); + String canonFile = Config.canonicalizeFileName(file); + String newCondition = (condition != null && !condition.isEmpty()) ? condition : null; + synchronized (breakpointLock) { + // Check if already exists - update condition if so + for (int i = 0; i < bpLines.length; i++) { + if (bpLines[i] == line && bpFiles[i].equals(canonFile)) { + // Copy-on-write: create new array for thread safety + String[] newConditions = bpConditions.clone(); + newConditions[i] = newCondition; + bpConditions = newConditions; + Log.info("Breakpoint updated: " + Config.shortenPath(canonFile) + ":" + line); + return; + } + } + // Add new breakpoint + int len = bpLines.length; + int[] newLines = new int[len + 1]; + String[] newFiles = new String[len + 1]; + String[] newConditions = new String[len + 1]; + System.arraycopy(bpLines, 0, newLines, 0, len); + System.arraycopy(bpFiles, 0, newFiles, 0, len); + System.arraycopy(bpConditions, 0, newConditions, 0, len); + newLines[len] = line; + newFiles[len] = canonFile; + newConditions[len] = newCondition; + bpLines = newLines; + bpFiles = newFiles; + bpConditions = newConditions; + rebuildBreakpointBounds(); } + updateHasSuspendConditions(); + Log.info("Breakpoint set: " + Config.shortenPath(canonFile) + ":" + line + + (newCondition != null ? " condition=" + newCondition : "")); } /** * Remove a breakpoint at the given file and line. */ public static void removeBreakpoint(String file, int line) { - String key = makeKey(file, line); - breakpoints.remove(key); - breakpointConditions.remove(key); - Log.info("Removed native breakpoint: " + key); + String canonFile = Config.canonicalizeFileName(file); + synchronized (breakpointLock) { + int idx = -1; + for (int i = 0; i < bpLines.length; i++) { + if (bpLines[i] == line && bpFiles[i].equals(canonFile)) { + idx = i; + break; + } + } + if (idx < 0) return; // not found + + int len = bpLines.length; + int[] newLines = new int[len - 1]; + String[] newFiles = new String[len - 1]; + String[] newConditions = new String[len - 1]; + System.arraycopy(bpLines, 0, newLines, 0, idx); + System.arraycopy(bpLines, idx + 1, newLines, idx, len - idx - 1); + System.arraycopy(bpFiles, 0, newFiles, 0, idx); + System.arraycopy(bpFiles, idx + 1, newFiles, idx, len - idx - 1); + System.arraycopy(bpConditions, 0, newConditions, 0, idx); + System.arraycopy(bpConditions, idx + 1, newConditions, idx, len - idx - 1); + bpLines = newLines; + bpFiles = newFiles; + bpConditions = newConditions; + rebuildBreakpointBounds(); + } + updateHasSuspendConditions(); + Log.info("Breakpoint removed: " + Config.shortenPath(canonFile) + ":" + line); } /** * Clear all breakpoints for a given file. */ public static void clearBreakpointsForFile(String file) { - String prefix = Config.canonicalizeFileName(file) + ":"; - breakpoints.keySet().removeIf(key -> key.startsWith(prefix)); - breakpointConditions.keySet().removeIf(key -> key.startsWith(prefix)); - Log.info("Cleared native breakpoints for: " + file); + String canonFile = Config.canonicalizeFileName(file); + synchronized (breakpointLock) { + // Count how many to keep + int keepCount = 0; + for (int i = 0; i < bpFiles.length; i++) { + if (!bpFiles[i].equals(canonFile)) keepCount++; + } + if (keepCount == bpFiles.length) return; // nothing to remove + + int[] newLines = new int[keepCount]; + String[] newFiles = new String[keepCount]; + String[] newConditions = new String[keepCount]; + int j = 0; + for (int i = 0; i < bpFiles.length; i++) { + if (!bpFiles[i].equals(canonFile)) { + newLines[j] = bpLines[i]; + newFiles[j] = bpFiles[i]; + newConditions[j] = bpConditions[i]; + j++; + } + } + bpLines = newLines; + bpFiles = newFiles; + bpConditions = newConditions; + rebuildBreakpointBounds(); + } + updateHasSuspendConditions(); + Log.info("Breakpoints cleared: " + Config.shortenPath(file)); } /** * Clear all breakpoints. */ public static void clearAllBreakpoints() { - breakpoints.clear(); - breakpointConditions.clear(); - Log.info("Cleared all native breakpoints"); + synchronized (breakpointLock) { + bpLines = new int[0]; + bpFiles = new String[0]; + bpConditions = new String[0]; + bpMinLine = Integer.MAX_VALUE; + bpMaxLine = Integer.MIN_VALUE; + bpMaxPathLen = 0; + } + updateHasSuspendConditions(); + Log.info("Breakpoints cleared: all"); } /** * Get breakpoint count (for debugging). */ public static int getBreakpointCount() { - return breakpoints.size(); + return bpLines.length; } /** @@ -291,7 +430,7 @@ public static boolean resumeNativeThread(long javaThreadId) { if (pc == null) { return false; } - Log.info("Resuming native thread: " + javaThreadId); + Log.debug("Resuming thread: " + javaThreadId); try { // Call debuggerResume() via reflection (Lucee7+ method) java.lang.reflect.Method resumeMethod = pc.getClass().getMethod("debuggerResume"); @@ -325,7 +464,8 @@ public static void resumeAllNativeThreads() { */ public static void startStepping(long threadId, StepMode mode, int currentDepth) { steppingThreads.put(threadId, new StepState(mode, currentDepth)); - Log.info("Start stepping: thread=" + threadId + " mode=" + mode + " depth=" + currentDepth); + updateHasSuspendConditions(); + Log.debug("Start stepping: thread=" + threadId + " mode=" + mode + " depth=" + currentDepth); } /** @@ -333,6 +473,7 @@ public static void startStepping(long threadId, StepMode mode, int currentDepth) */ public static void stopStepping(long threadId) { steppingThreads.remove(threadId); + updateHasSuspendConditions(); } /** @@ -359,14 +500,14 @@ public static int getStackDepth(PageContext pc) { */ public static void onSuspend(PageContext pc, String file, int line, String label) { long threadId = Thread.currentThread().getId(); - Log.info("Native suspend: thread=" + threadId + " file=" + file + " line=" + line + " label=" + label); + Log.debug("Suspend: thread=" + threadId + " file=" + Config.shortenPath(file) + " line=" + line + " label=" + label); // Check if we were stepping BEFORE clearing state StepState stepState = steppingThreads.remove(threadId); boolean wasStepping = (stepState != null); // Check if we hit a breakpoint (breakpoint wins over step) - boolean hitBreakpoint = breakpoints.containsKey(makeKey(file, line)); + boolean hitBreakpoint = hasBreakpoint(file, line); // Check if there's a pending exception for this thread (from onException) Throwable pendingException = pendingExceptions.remove(threadId); @@ -379,8 +520,14 @@ public static void onSuspend(PageContext pc, String file, int line, String label // Include the exception if we're suspending due to one suspendLocations.put(threadId, new SuspendLocation(file, line, label, pendingException)); - // Fire appropriate callback - breakpoint takes precedence over step - if (hitBreakpoint) { + // Fire appropriate callback - exception takes precedence, then breakpoint, then step + if (pendingException != null) { + // Stopped due to uncaught exception + Consumer callback = onNativeExceptionCallback; + if (callback != null) { + callback.accept(threadId); + } + } else if (hitBreakpoint) { // Stopped at breakpoint (no label for line breakpoints) BiConsumer callback = onNativeSuspendCallback; if (callback != null) { @@ -406,7 +553,7 @@ public static void onSuspend(PageContext pc, String file, int line, String label */ public static void onResume(PageContext pc) { long threadId = Thread.currentThread().getId(); - Log.info("Native resume: thread=" + threadId); + Log.debug("Resume: thread=" + threadId); // Remove from suspended threads map and location nativelySuspendedThreads.remove(threadId); @@ -427,6 +574,7 @@ public static boolean isDapClientConnected() { */ public static void setDapClientConnected(boolean connected) { dapClientConnected = connected; + updateHasSuspendConditions(); Log.info("DAP client connected: " + connected); } @@ -436,7 +584,8 @@ public static void setDapClientConnected(boolean connected) { */ public static void setBreakOnUncaughtExceptions(boolean enabled) { breakOnUncaughtExceptions = enabled; - Log.info("Break on uncaught exceptions: " + enabled); + updateHasSuspendConditions(); + Log.info("Exception breakpoints: " + (enabled ? "uncaught" : "none")); } /** @@ -446,6 +595,30 @@ public static boolean shouldBreakOnUncaughtExceptions() { return breakOnUncaughtExceptions && dapClientConnected; } + /** + * Set whether to forward System.out/err to DAP client. + * Called from DapServer when handling attach request. + */ + public static void setLogSystemOutput(boolean enabled) { + logSystemOutput = enabled; + Log.setLogSystemOutput(enabled); + Log.info("Log system output: " + enabled); + } + + /** + * Called by Lucee's DebuggerPrintStream when output is written to System.out/err. + * Forwards to DAP client if logSystemOutput is enabled. + * + * @param text The text that was written + * @param isStdErr true if stderr, false if stdout + */ + public static void onOutput(String text, boolean isStdErr) { + if (!logSystemOutput || !dapClientConnected) { + return; + } + Log.systemOutput(text, isStdErr); + } + /** * Called by Lucee when an exception is about to be handled. * Returns true if we should suspend to let the debugger inspect. @@ -456,7 +629,11 @@ public static boolean shouldBreakOnUncaughtExceptions() { * @return true to suspend execution */ public static boolean onException(PageContext pc, Throwable exception, boolean caught) { - Log.info("onException called: caught=" + caught + ", exception=" + exception.getClass().getName() + ", breakOnUncaught=" + breakOnUncaughtExceptions + ", dapConnected=" + dapClientConnected); + Log.debug("onException called: caught=" + caught + ", exception=" + exception.getClass().getName() + ", breakOnUncaught=" + breakOnUncaughtExceptions + ", dapConnected=" + dapClientConnected); + + // Log exception to debug console if enabled (both caught and uncaught) + Log.exception(exception); + // Only handle uncaught exceptions for now if (caught) { return false; @@ -467,7 +644,7 @@ public static boolean onException(PageContext pc, Throwable exception, boolean c long threadId = Thread.currentThread().getId(); pendingExceptions.put(threadId, exception); } - Log.info("onException returning: " + shouldSuspend); + Log.debug("onException returning: " + shouldSuspend); return shouldSuspend; } @@ -477,21 +654,43 @@ public static boolean onException(PageContext pc, Throwable exception, boolean c * Must be fast - this is on the hot path. */ public static boolean shouldSuspend(PageContext pc, String file, int line) { - // Early exit if no DAP client connected - nothing to notify - if (!dapClientConnected) { + // Fast path - nothing could possibly cause a suspend + if (!hasSuspendConditions) { return false; } - // Check breakpoints first (most common case) - String key = makeKey(file, line); - if (breakpoints.containsKey(key)) { - // Check if there's a condition - String condition = breakpointConditions.get(key); - if (condition != null) { - // Evaluate condition - only suspend if true - return evaluateCondition(pc, condition); + // Check breakpoints with fast bounds rejection + // Grab local refs - volatile hasSuspendConditions provides memory barrier + int[] lines = bpLines; + if (lines.length > 0) { + // Bounds check - reject 99% of lines instantly + if (line >= bpMinLine && line <= bpMaxLine) { + // Line is in range - check for matching line numbers first + // Only canonicalize file path if we find a line match (rare) + String[] files = bpFiles; + String[] conditions = bpConditions; + String canonFile = null; // lazy init + for (int i = 0; i < lines.length; i++) { + if (lines[i] == line) { + // Line matches - now check file (canonicalize once) + if (canonFile == null) { + // Length check before expensive canonicalization + if (file.length() > bpMaxPathLen) { + break; // file too long, can't match any breakpoint + } + canonFile = Config.canonicalizeFileName(file); + } + if (files[i].equals(canonFile)) { + // Hit! Check condition if present + String condition = conditions[i]; + if (condition != null) { + return evaluateCondition(pc, condition); + } + return true; + } + } + } } - return true; } // Check stepping state @@ -545,11 +744,14 @@ private static boolean evaluateCondition(PageContext pc, String condition) { * Check if a breakpoint exists at the given file and line. */ public static boolean hasBreakpoint(String file, int line) { - String key = makeKey(file, line); - return breakpoints.containsKey(key); - } - - private static String makeKey(String file, int line) { - return Config.canonicalizeFileName(file) + ":" + line; + String canonFile = Config.canonicalizeFileName(file); + int[] lines = bpLines; + String[] files = bpFiles; + for (int i = 0; i < lines.length; i++) { + if (lines[i] == line && files[i].equals(canonFile)) { + return true; + } + } + return false; } } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java index 536015b..ca76b7f 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java @@ -32,6 +32,7 @@ public class NativeLuceeVm implements ILuceeVm { private Consumer stepEventCallback = null; private BiConsumer breakpointEventCallback = null; private BiConsumer nativeBreakpointEventCallback = null; + private Consumer exceptionEventCallback = null; private Consumer breakpointsChangedCallback = null; private AtomicInteger breakpointID = new AtomicInteger(); @@ -66,6 +67,13 @@ public NativeLuceeVm(Config config) { stepEventCallback.accept(javaThreadId); } }); + + // Register native exception callback + NativeDebuggerListener.setOnNativeExceptionCallback(javaThreadId -> { + if (exceptionEventCallback != null) { + exceptionEventCallback.accept(javaThreadId); + } + }); } private DapBreakpointID nextDapBreakpointID() { @@ -180,7 +188,7 @@ public IDebugFrame[] getStackTrace(long threadID) { frameCache.put(frame.getId(), frame); } - Log.debug("getStackTrace: returning " + frames.length + " frames for thread " + threadID); + Log.trace("getStackTrace: returning " + frames.length + " frames for thread " + threadID); return frames; } @@ -337,4 +345,15 @@ public Either> evaluate(int frame } return GlobalIDebugManagerHolder.debugManager.evaluate((Long)(long)frameID, expr); } + + @Override + public void registerExceptionEventCallback(Consumer cb) { + exceptionEventCallback = cb; + } + + @Override + public Throwable getExceptionForThread(long threadId) { + NativeDebuggerListener.SuspendLocation loc = NativeDebuggerListener.getSuspendLocation(threadId); + return loc != null ? loc.exception : null; + } } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java index bef9265..44ed925 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java @@ -373,7 +373,6 @@ private static synchronized boolean initReflection( ClassLoader luceeClassLoader getDisplayPathMethod = pageSourceClass.getMethod( "getDisplayPath" ); nativeFrameSupportAvailable = true; - Log.info( "Native Lucee7 debugger frame support detected and enabled" ); return true; } catch ( Throwable e ) { @@ -438,9 +437,9 @@ public static IDebugFrame[] getNativeFrames( PageContext pageContext, ValTracker // If no frames from native stack, try to create synthetic frame from suspend location if ( result.isEmpty() && threadId >= 0 ) { - Log.debug( "Checking suspend location for thread " + threadId + ": " + (location != null ? location.file + ":" + location.line : "null") ); + Log.trace( "Checking suspend location for thread " + threadId + ": " + (location != null ? location.file + ":" + location.line : "null") ); if ( location != null && location.file != null && location.line > 0 ) { - Log.debug( "Creating synthetic frame for top-level code: " + location.file + ":" + location.line + (location.label != null ? " label=" + location.label : "") ); + Log.trace( "Creating synthetic frame for top-level code: " + location.file + ":" + location.line + (location.label != null ? " label=" + location.label : "") ); result.add( new NativeDebugFrame( pageContext, valTracker, location.file, location.line, location.label, exception ) ); } } diff --git a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java index da98d89..4c2db74 100644 --- a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java +++ b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java @@ -178,6 +178,8 @@ private boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoa final Method isDapClientConnectedMethod = nativeListenerClass.getMethod("isDapClientConnected"); final Method onExceptionMethod = nativeListenerClass.getMethod("onException", pageContextClass, Throwable.class, boolean.class); + final Method onOutputMethod = nativeListenerClass.getMethod("onOutput", + String.class, boolean.class); // Create proxy in Lucee's classloader, delegating to extension's implementation Object listenerProxy = java.lang.reflect.Proxy.newProxyInstance( @@ -190,7 +192,8 @@ private boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoa case "onResume": return onResumeMethod.invoke(null, args); case "shouldSuspend": return shouldSuspendMethod.invoke(null, args); case "onException": return onExceptionMethod.invoke(null, args); - default: return null; // Default methods like onException have defaults + case "onOutput": return onOutputMethod.invoke(null, args); + default: return null; } } ); @@ -215,7 +218,7 @@ private boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoa * Shuts down the DAP server to free the port. */ public void finalize() { - Log.info("Extension finalizing - shutting down DAP server"); + Log.debug("Extension finalizing - shutting down DAP server"); DapServer.shutdown(); } } diff --git a/vscode-client/package.json b/vscode-client/package.json index b2f5691..f5f0c87 100644 --- a/vscode-client/package.json +++ b/vscode-client/package.json @@ -160,6 +160,31 @@ ], "default": "auto", "description": "How paths returned from the debugger should be normalized (none, auto, posix, or windows)." + }, + "logColor": { + "type": "boolean", + "default": true, + "description": "Enable ANSI colors in debugger log output." + }, + "logLevel": { + "type": "string", + "enum": [ + "error", + "info", + "debug" + ], + "default": "info", + "description": "Debugger log verbosity level." + }, + "logExceptions": { + "type": "boolean", + "default": false, + "description": "Log exceptions to the debug console." + }, + "logSystemOutput": { + "type": "boolean", + "default": false, + "description": "Show System.out/err in the debug console." } } } From 4ca2fe3e6b20662d99511d4e56a932f5a6391411 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 5 Dec 2025 15:26:06 +0100 Subject: [PATCH 04/14] LDEV-1402 switch to requiring LUCEE_DEBUGGER_SECRET --- DAP_TEST_CLIENT_PLAN.md | 98 ---------- NATIVE_DEBUGGING_PLAN.md | 179 ------------------ .../src/main/java/luceedebug/Agent.java | 10 - .../src/main/java/luceedebug/DapServer.java | 101 ++++++++-- .../src/main/java/luceedebug/EnvUtil.java | 33 +++- .../java/luceedebug/LuceeTransformer.java | 69 +------ .../coreinject/NativeDebuggerListener.java | 39 +++- .../extension/ExtensionActivator.java | 105 +++++++--- vscode-client/package.json | 14 +- 9 files changed, 234 insertions(+), 414 deletions(-) delete mode 100644 DAP_TEST_CLIENT_PLAN.md delete mode 100644 NATIVE_DEBUGGING_PLAN.md diff --git a/DAP_TEST_CLIENT_PLAN.md b/DAP_TEST_CLIENT_PLAN.md deleted file mode 100644 index c9d1d58..0000000 --- a/DAP_TEST_CLIENT_PLAN.md +++ /dev/null @@ -1,98 +0,0 @@ -# CFML DAP Client for Debugger Testing - -## Goal - -Create a pure CFML DAP client to test luceedebug without VS Code involvement. This enables automated testing and can be bundled with the extension for users. - -## Architecture - -``` -Instance A (test runner) Instance B (debuggee) -───────────────────────── ──────────────────────── -DapClient.cfc luceedebug + Lucee7 -test-*.cfm target scripts - │ │ - │ DAP socket ──────────────────> │ port 10000 - │ HTTP trigger ─────────────────> │ port 8888 -``` - -**Critical**: Test runner must NOT run with debugger enabled to avoid deadlock. - -## Files to Create - -### 1. `profiling/DapClient.cfc` - -DAP protocol client with: - -- **Connection**: TCP socket via `java.net.Socket` -- **Framing**: `Content-Length: N\r\n\r\n{json}` -- **Sequence tracking**: Auto-increment request seq -- **Response/event handling**: Separate queues for responses vs events - -Methods: - -- `connect( host, port )` / `disconnect()` -- `initialize()` - DAP initialize handshake -- `setBreakpoints( path, lines[] )` - Set breakpoints for file -- `configurationDone()` - Signal ready to run -- `continue( threadId )` / `continueAll()` -- `stackTrace( threadId )` - Get stack frames -- `scopes( frameId )` - Get variable scopes -- `variables( variablesReference )` - Get variables -- `threads()` - List threads -- `waitForEvent( type, timeoutMs )` - Wait for specific event (stopped, thread, etc.) -- `stepOver( threadId )` / `stepIn( threadId )` / `stepOut( threadId )` - -### 2. `profiling/test-dap-breakpoint.cfm` - -Test native breakpoint flow: - -1. Connect to debuggee DAP port -2. Set breakpoint on target file -3. HTTP request to trigger target (async thread) -4. Wait for "stopped" event -5. Verify stack trace shows correct file/line -6. Continue and verify request completes - -### 3. `profiling/dap-target.cfm` - -Simple target script in debuggee webroot: - -```cfml -function testFunc( name ) { - var greeting = "Hello, #name#!"; // <- breakpoint here - return greeting; -} -result = testFunc( "World" ); -``` - -## DAP Protocol Notes - -Message format: - -``` -Content-Length: 119\r\n -\r\n -{"seq":1,"type":"request","command":"initialize","arguments":{"clientID":"cfml-test","adapterID":"luceedebug"}} -``` - -Key request/response pairs: - -- `initialize` -> capabilities -- `setBreakpoints` -> breakpoint[] with verified flag -- `configurationDone` -> empty -- `continue` -> empty -- `stackTrace` -> stackFrames[] -- `threads` -> threads[] - -Key events: - -- `stopped` - thread hit breakpoint (body.threadId, body.reason) -- `thread` - thread started/exited - -## Implementation Order - -1. DapClient.cfc - core protocol -2. Basic test - connect, initialize, disconnect -3. Breakpoint test - full flow with target script -4. Step test - stepOver/stepIn/stepOut (once Phase 2 native stepping is done) diff --git a/NATIVE_DEBUGGING_PLAN.md b/NATIVE_DEBUGGING_PLAN.md deleted file mode 100644 index 6b37dfb..0000000 --- a/NATIVE_DEBUGGING_PLAN.md +++ /dev/null @@ -1,179 +0,0 @@ -# Native Lucee Debugging Plan - -Goal: Make debugging Lucee as easy as possible - install extension, connect VS Code, go. - -## Current State (done) - -- `DEBUGGER_ENABLED` env var/system property enables debug mode -- Native `DebuggerFrame` stack in PageContextImpl (push/pop on UDF calls) -- `ExecutionLog.start(pos, line, id)` passes line numbers from compiler -- `DebuggerExecutionLog` updates frame line numbers -- luceedebug can read native frames via reflection fallback (`NativeDebugFrame.java`) -- **Tested working**: luceedebug agent + Lucee7 with native frames (2025-12-03) - - Agent starts, connects to JDWP, DAP server listens on port 10000 - - Native frames show correct function names, file paths, local variables, arguments - - Test script: `profiling/test-native-frames-with-agent.bat` - -## Phase 1: Native Breakpoints ✅ DONE (Lucee core parts) - -### Lucee Core Changes - IMPLEMENTED - -1. ✅ **DebuggerListener interface** (`lucee.runtime.debug.DebuggerListener`) - ```java - public interface DebuggerListener { - void onSuspend(PageContext pc, String file, int line, String label); - void onResume(PageContext pc); - boolean hasBreakpoint(String file, int line); - } - ``` - -2. ✅ **DebuggerRegistry** (`lucee.runtime.debug.DebuggerRegistry`) - - Static singleton for listener registration - - `setListener(listener)` / `getListener()` / `hasListener()` - - luceedebug accesses via reflection - -3. ✅ **Breakpoint Check in DebuggerExecutionLog.start()** - ```java - public void start(int pos, int line, String id) { - DebuggerFrame frame = pci.getTopmostDebuggerFrame(); - if (frame != null) { - frame.setLine(line); - DebuggerListener listener = DebuggerRegistry.getListener(); - if (listener != null && listener.hasBreakpoint(frame.getFile(), line)) { - pci.debuggerSuspend(null); - } - } - } - ``` - -4. ✅ **Thread Suspension with callbacks** in PageContextImpl - - `debuggerSuspend(label)` calls `listener.onSuspend()` before blocking - - Calls `listener.onResume()` after unblocking - - Tracks `debuggerTotalSuspendedNanos` for timeout adjustment - -5. ⏳ **Timeout Pausing** - `debuggerTotalSuspendedNanos` tracked but not yet wired into timeout calc - -### luceedebug Changes - IMPLEMENTED - -1. ✅ **NativeDebuggerListener** class (`luceedebug.coreinject.NativeDebuggerListener`) - - Breakpoint map: `ConcurrentHashMap` for "file:line" -> true - - Tracks natively suspended threads: `ConcurrentHashMap>` - - `onSuspend()` / `onResume()` / `hasBreakpoint()` interface methods - - `resumeNativeThread()` calls `debuggerResume()` via reflection - -2. ✅ **Register via reflection** in `LuceeTransformer.registerNativeDebuggerListener()` - - Creates dynamic proxy implementing `DebuggerListener` - - Registers with `DebuggerRegistry.setListener()` - - Gracefully falls back if DebuggerRegistry not available (pre-Lucee7) - -3. ✅ **DAP stopped event** from `onSuspend` callback - - `LuceeVm.registerNativeBreakpointEventCallback()` receives Java thread ID - - `DapServer` sends `StoppedEventArguments` to VS Code - -4. ✅ **Continue support** for native threads - - `LuceeVm.continue_(long)` tries native resume first, falls back to JDWP - - `continueAll()` resumes both native and JDWP suspended threads - -5. ✅ **Wire breakpoints to NativeDebuggerListener** - - `bindBreakpoints()` clears and adds native breakpoints for file - - `clearAllBreakpoints()` clears native breakpoints - -6. ✅ **Native-only mode flag** (skip JDWP breakpoint registration) - - `NativeDebuggerListener.setNativeOnlyMode(true)` enables native-only mode - - When enabled, `bindBreakpoints()` skips JDWP registration, returns all breakpoints as "bound" - - `clearAllBreakpoints()` skips JDWP operations in native-only mode - - NOTE: This is for hybrid agent mode. True extension mode needs `NativeLuceeVm` (see below) - -7. ✅ **NativeLuceeVm** - ILuceeVm implementation without JDWP (stubbed) - - For true extension deployment (no agent, no JDWP, no bytecode instrumentation) - - Implements `ILuceeVm` interface using only native Lucee7 debugging APIs - - No `VirtualMachine` dependency - - Refactored `ILuceeVm` to remove JDWP types (`ThreadReference` -> `ThreadInfo`, `JdwpThreadID` -> `Long`) - - ✅ Thread listing via `CFMLFactoryImpl.getActivePageContexts()` (reflection) - - Stack frames via native `DebuggerFrame` stack (working) - - Variables via `CfValueDebuggerBridge` (working) - - ⏳ Native stepping - needs Phase 2 implementation - -## Phase 2: Stepping - -### Step Into -- Set flag `stepMode = STEP_INTO` -- Next `start()` call suspends - -### Step Over -- Record current frame depth -- Set flag `stepMode = STEP_OVER, stepDepth = currentDepth` -- Suspend when `start()` called at same or lower depth - -### Step Out -- Record current frame depth -- Set flag `stepMode = STEP_OUT, stepDepth = currentDepth` -- Suspend when frame depth < stepDepth - -```java -// In DebuggerExecutionLog.start() -if (stepMode == STEP_INTO) { - suspend(); -} else if (stepMode == STEP_OVER && currentDepth <= stepDepth) { - suspend(); -} else if (stepMode == STEP_OUT && currentDepth < stepDepth) { - suspend(); -} -``` - -## Phase 3: Conditional Breakpoints & Watches - -- Breakpoint conditions: evaluate CFML expression, break if truthy -- Watch expressions: evaluate on suspend, show in VS Code -- Both use existing `Evaluate.call()` infrastructure - -## Phase 4: Programmatic Breakpoints ✅ DONE - -BIF `breakpoint()` - like JavaScript's `debugger;` statement. - -```cfml -// Simple - pause here -breakpoint(); - -// Conditional - only pause when condition is true -breakpoint( user.isAdmin() ); - -// Labeled - shows in debugger UI -breakpoint( label="after auth check" ); -``` - -Implementation: -```java -public class Breakpoint extends BIF { - public static Object call(PageContext pc) { - return call(pc, true, null); - } - public static Object call(PageContext pc, boolean condition, String label) { - if (condition && PageContextImpl.DEBUGGER_ENABLED) { - ((PageContextImpl) pc).debuggerSuspend(label); - } - return null; - } -} -``` - -Benefits: -- Conditional breakpoints without IDE config -- Debug specific code paths -- Zero cost when DEBUGGER_ENABLED=false -- Works in production - flip flag, attach, hit code path - -## Open Questions - -1. ✅ **Loader interface changes** - RESOLVED: No loader changes needed. DebuggerListener/Registry in core, accessed via reflection. -2. **Extension loading order** - debugger extension needs to init early -3. ✅ **Multiple debuggers** - RESOLVED: Single listener only. Keep it simple. -4. **Remote debugging** - DAP over network vs localhost only? -5. **Performance** - hash lookup per line acceptable? Profile it. (hasBreakpoint called on every line) - -## Migration Path - -1. Ship Lucee 7.1 with native debug support -2. Ship luceedebug 2.0 as extension (no agent required) -3. Keep agent mode working for older Lucee versions -4. Deprecate agent mode eventually diff --git a/luceedebug/src/main/java/luceedebug/Agent.java b/luceedebug/src/main/java/luceedebug/Agent.java index 5eb21b1..2a8ff0a 100644 --- a/luceedebug/src/main/java/luceedebug/Agent.java +++ b/luceedebug/src/main/java/luceedebug/Agent.java @@ -186,16 +186,6 @@ private static Map linearizedCoreInjectClasses() { result.put("luceedebug.coreinject.frame.Frame$FrameContext", 1); result.put("luceedebug.coreinject.frame.Frame$FrameContext$SupplierOrNull", 1); result.put("luceedebug.coreinject.frame.DummyFrame", 1); - result.put("luceedebug.coreinject.frame.NativeDebugFrame", 1); - - // Native debugger listener for Lucee7+ native breakpoints - result.put("luceedebug.coreinject.NativeDebuggerListener", 0); - result.put("luceedebug.coreinject.NativeDebuggerListener$1", 0); - result.put("luceedebug.coreinject.NativeDebuggerListener$StepState", 0); - result.put("luceedebug.coreinject.StepMode", 0); - - // Native-only LuceeVm implementation (no JDWP) - result.put("luceedebug.coreinject.NativeLuceeVm", 0); return result; } diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index f4d663a..1cf4e99 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -214,21 +214,18 @@ static public DapEntry createForSocket(ILuceeVm luceeVm, Config config, String h luceedebug.coreinject.NativeDebuggerListener.setDapClientConnected(true); try { - Log.info("Creating DAP entry..."); var rawIn = socket.getInputStream(); var rawOut = socket.getOutputStream(); var dapEntry = create(luceeVm, config, rawIn, rawOut); - Log.info("DAP launcher created, starting listening..."); // Enable DAP output for this client Log.setDapClient(dapEntry.server.clientProxy_); var future = dapEntry.launcher.startListening(); - Log.info("DAP launcher started, waiting for connection to close..."); try { future.get(); // block until the connection closes } catch (Exception e) { Log.error("Launcher future exception", e); } - Log.info("DAP client disconnected from " + clientAddr + ":" + clientPort); + Log.debug("DAP client disconnected from " + clientAddr + ":" + clientPort); } catch (Exception e) { Log.error("DAP client error: " + e.getClass().getName(), e); } finally { @@ -236,7 +233,7 @@ static public DapEntry createForSocket(ILuceeVm luceeVm, Config config, String h luceedebug.coreinject.NativeDebuggerListener.setDapClientConnected(false); Log.setDapClient(null); // Disable DAP output try { socket.close(); } catch (Exception ignored) {} - Log.info("DAP client socket closed for " + clientAddr + ":" + clientPort); + Log.debug("DAP client socket closed for " + clientAddr + ":" + clientPort); } logger.finest("debugger connection closed"); @@ -264,15 +261,17 @@ static public DapEntry createForSocket(ILuceeVm luceeVm, Config config, String h /** * Shutdown the DAP server. Called on extension uninstall/reinstall. * Uses System.getProperties() to find socket from previous classloaders. + * Uses reflection to avoid OSGi classloader identity issues. */ public static void shutdown() { // Try to get socket from JVM-wide properties (survives classloader changes) Object storedSocket = System.getProperties().get(SOCKET_PROPERTY); - if (storedSocket instanceof ServerSocket) { - ServerSocket socket = (ServerSocket) storedSocket; - Log.debug("shutdown() - found socket in system properties, closing..."); + if (storedSocket != null) { + // Use reflection - instanceof may fail across OSGi classloaders + Log.debug("shutdown() - found socket in system properties, closing via reflection..."); try { - socket.close(); + java.lang.reflect.Method closeMethod = storedSocket.getClass().getMethod("close"); + closeMethod.invoke(storedSocket); Log.debug("shutdown() - socket closed"); } catch (Exception e) { Log.error("shutdown() - socket close error", e); @@ -282,7 +281,7 @@ public static void shutdown() { Log.debug("shutdown() - no socket in system properties"); } - // Also close our local static reference if set + // Also close our local static reference if set (same classloader case) if (activeServerSocket != null) { try { activeServerSocket.close(); @@ -385,16 +384,22 @@ public CompletableFuture attach(Map args) { // Configure logging from launch.json (before other logging) configureLogging(args); + // Log version info first + String luceeVersion = getLuceeVersion(); + Log.info("luceedebug " + Constants.version + " connected to Lucee " + luceeVersion); + + // Validate secret from launch.json + if (!validateSecret(args)) { + clientProxy_.terminated(new org.eclipse.lsp4j.debug.TerminatedEventArguments()); + return CompletableFuture.completedFuture(null); + } + pathTransforms = tryMungePathTransforms(args.get("pathTransforms")); config_.setStepIntoUdfDefaultValueInitFrames(getBoolOrFalseIfNonBool(args.get("stepIntoUdfDefaultValueInitFrames"))); clientProxy_.initialized(); - // Log version info - String luceeVersion = getLuceeVersion(); - Log.info("luceedebug " + Constants.version + " connected to Lucee " + luceeVersion); - if (pathTransforms.size() == 0) { Log.info("No path transforms configured"); } @@ -411,6 +416,62 @@ public CompletableFuture attach(Map args) { return CompletableFuture.completedFuture(null); } + // Track whether secret has been validated for this session + private boolean secretValidated = false; + + /** + * Validate the secret from launch.json. + * Works for both native mode (via ExtensionActivator) and agent mode (direct validation). + * + * @param args The attach arguments containing the secret + * @return true if secret is valid, false otherwise + */ + private boolean validateSecret(Map args) { + Object secretObj = args.get("secret"); + String clientSecret = (secretObj instanceof String) ? ((String) secretObj).trim() : null; + + if (clientSecret == null || clientSecret.isEmpty()) { + Log.error("No secret provided in launch.json"); + return false; + } + + // Try native mode first (Lucee 7.1+ extension) + try { + Class activatorClass = Class.forName("luceedebug.extension.ExtensionActivator"); + java.lang.reflect.Method registerMethod = activatorClass.getMethod("registerListener", String.class); + Boolean registered = (Boolean) registerMethod.invoke(null, clientSecret); + if (registered) { + secretValidated = true; + return true; + } else { + Log.error("Failed to register debugger - invalid secret"); + return false; + } + } catch (ClassNotFoundException e) { + // Not in native mode, fall through to agent mode validation + } catch (Exception e) { + Log.error("Error calling ExtensionActivator.registerListener", e); + return false; + } + + // Agent mode - validate secret directly + String expectedSecret = EnvUtil.getDebuggerSecret(); + if (expectedSecret == null) { + // No secret configured on server - allow any secret for backwards compatibility? + // No - require secret to be set for security + Log.error("LUCEE_DEBUGGER_SECRET not set on server"); + return false; + } + + if (!expectedSecret.equals(clientSecret)) { + Log.error("Invalid secret"); + return false; + } + + secretValidated = true; + return true; + } + /** * Configure logging from launch.json settings. * Supports: logColor (boolean), logLevel (error|info|debug), logExceptions (boolean), logSystemOutput (boolean) @@ -561,9 +622,16 @@ public CompletableFuture variables(VariablesArguments args) { @Override public CompletableFuture setBreakpoints(SetBreakpointsArguments args) { + // Don't accept breakpoints if secret wasn't validated + if (!secretValidated) { + var response = new SetBreakpointsResponse(); + response.setBreakpoints(new Breakpoint[0]); + return CompletableFuture.completedFuture(response); + } + final var idePath = new RawIdePath(args.getSource().getPath()); final var serverAbsPath = new CanonicalServerAbsPath(applyPathTransformsIdeToCf(args.getSource().getPath())); - + logger.finest("bp for " + idePath.get() + " -> " + serverAbsPath.get()); final int size = args.getBreakpoints().length; @@ -578,7 +646,7 @@ public CompletableFuture setBreakpoints(SetBreakpointsAr for (IBreakpoint bp : luceeVm_.bindBreakpoints(idePath, serverAbsPath, lines, exprs)) { result.add(map_cfBreakpoint_to_lsp4jBreakpoint(bp)); } - + var response = new SetBreakpointsResponse(); response.setBreakpoints(result.toArray(len -> new Breakpoint[len])); @@ -677,6 +745,7 @@ public CompletableFuture pause(PauseArguments args) { @Override public CompletableFuture disconnect(DisconnectArguments args) { + Log.info("DAP client disconnected"); luceeVm_.clearAllBreakpoints(); luceeVm_.continueAll(); return CompletableFuture.completedFuture(null); diff --git a/luceedebug/src/main/java/luceedebug/EnvUtil.java b/luceedebug/src/main/java/luceedebug/EnvUtil.java index aba59ae..22d5a19 100644 --- a/luceedebug/src/main/java/luceedebug/EnvUtil.java +++ b/luceedebug/src/main/java/luceedebug/EnvUtil.java @@ -28,31 +28,48 @@ public static String getSystemPropOrEnvVar(String propertyName) { } /** - * Check if debugger is enabled via environment variable or system property. - * Checks "lucee.debugger.enabled" / "LUCEE_DEBUGGER_ENABLED". + * Get debugger secret from environment/system property. + * Checks "lucee.debugger.secret" / "LUCEE_DEBUGGER_SECRET". * - * @return true if debugger is enabled + * @return the secret, or null if not set (debugger disabled) + */ + public static String getDebuggerSecret() { + String secret = getSystemPropOrEnvVar("lucee.debugger.secret"); + if (secret != null && !secret.trim().isEmpty()) { + return secret.trim(); + } + return null; + } + + /** + * Check if debugger is enabled (secret is set). + * + * @return true if debugger secret is configured */ public static boolean isDebuggerEnabled() { - String value = getSystemPropOrEnvVar("lucee.debugger.enabled"); - return "true".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value) || "1".equals(value); + return getDebuggerSecret() != null; } /** * Get debugger port from environment/system property. * Checks "lucee.debugger.port" / "LUCEE_DEBUGGER_PORT". + * Defaults to 9999 if secret is set but port is not. * - * @return the port number, or -1 if not set (debugger disabled) + * @return the port number, or -1 if debugger disabled (no secret) */ public static int getDebuggerPort() { + // No port if no secret + if (getDebuggerSecret() == null) { + return -1; + } String port = getSystemPropOrEnvVar("lucee.debugger.port"); if (port == null || port.isEmpty()) { - return -1; + return 9999; // default port } try { return Integer.parseInt(port); } catch (NumberFormatException e) { - return -1; + return 9999; } } } diff --git a/luceedebug/src/main/java/luceedebug/LuceeTransformer.java b/luceedebug/src/main/java/luceedebug/LuceeTransformer.java index f010704..bd65cf2 100644 --- a/luceedebug/src/main/java/luceedebug/LuceeTransformer.java +++ b/luceedebug/src/main/java/luceedebug/LuceeTransformer.java @@ -2,12 +2,10 @@ import java.lang.instrument.*; import java.lang.reflect.Method; +import java.security.ProtectionDomain; import org.objectweb.asm.*; -import java.security.ProtectionDomain; -import java.util.ArrayList; - public class LuceeTransformer implements ClassFileTransformer { private final String jdwpHost; private final int jdwpPort; @@ -87,9 +85,6 @@ else if (className.equals("lucee/runtime/PageContextImpl")) { System.out.println("[luceedebug] Loaded " + GlobalIDebugManagerHolder.debugManager + " with ClassLoader '" + GlobalIDebugManagerHolder.debugManager.getClass().getClassLoader() + "'"); GlobalIDebugManagerHolder.debugManager.spawnWorker(config, jdwpHost, jdwpPort, debugHost, debugPort); - - // Register native debugger listener for Lucee7+ native breakpoints - registerNativeDebuggerListener(loader); } catch (Throwable e) { e.printStackTrace(); @@ -240,66 +235,4 @@ protected ClassLoader getClassLoader() { } } - /** - * Register our NativeDebuggerListener with Lucee's DebuggerRegistry (if available). - * This enables native breakpoints in Lucee7+ without JDWP instrumentation. - * - * The listener is registered via reflection since DebuggerListener/DebuggerRegistry - * are in Lucee core, not the loader. - */ - private void registerNativeDebuggerListener(ClassLoader luceeLoader) { - try { - // Check if DebuggerRegistry exists (Lucee7+ feature) - Class registryClass; - try { - registryClass = luceeLoader.loadClass("lucee.runtime.debug.DebuggerRegistry"); - } catch (ClassNotFoundException e) { - System.out.println("[luceedebug] DebuggerRegistry not found - native breakpoints not available (pre-Lucee7)"); - return; - } - - // Load the DebuggerListener interface - Class listenerInterface = luceeLoader.loadClass("lucee.runtime.debug.DebuggerListener"); - - // Load our NativeDebuggerListener class (already injected into core loader) - Class nativeListenerClass = GlobalIDebugManagerHolder.luceeCoreLoader.loadClass("luceedebug.coreinject.NativeDebuggerListener"); - Class pageContextClass = luceeLoader.loadClass("lucee.runtime.PageContext"); - - // Cache method lookups - shouldSuspend is on the hot path (called every line) - final Method onSuspendMethod = nativeListenerClass.getMethod("onSuspend", - pageContextClass, String.class, int.class, String.class); - final Method onResumeMethod = nativeListenerClass.getMethod("onResume", pageContextClass); - final Method shouldSuspendMethod = nativeListenerClass.getMethod("shouldSuspend", - pageContextClass, String.class, int.class); - - // Create a dynamic proxy that implements DebuggerListener and delegates to our static methods - Object listenerProxy = java.lang.reflect.Proxy.newProxyInstance( - luceeLoader, - new Class[] { listenerInterface }, - (proxy, method, args) -> { - String methodName = method.getName(); - switch (methodName) { - case "onSuspend": - return onSuspendMethod.invoke(null, args); - case "onResume": - return onResumeMethod.invoke(null, args); - case "shouldSuspend": - return shouldSuspendMethod.invoke(null, args); - default: - throw new UnsupportedOperationException("Unknown method: " + methodName); - } - } - ); - - // Register the listener - Method setListener = registryClass.getMethod("setListener", listenerInterface); - setListener.invoke(null, listenerProxy); - - System.out.println("[luceedebug] Registered native debugger listener for Lucee7+ breakpoints"); - } catch (Throwable e) { - System.out.println("[luceedebug] Failed to register native debugger listener: " + e.getMessage()); - e.printStackTrace(); - // Don't exit - native breakpoints are optional, JDWP breakpoints still work - } - } } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java index b6469ba..48cfb4e 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java @@ -33,6 +33,13 @@ enum StepMode { */ public class NativeDebuggerListener { + /** + * Name for this debugger (shown in Lucee logs). + */ + public static String getName() { + return NativeDebuggerListener.class.getName(); + } + /** * Breakpoint storage - parallel arrays for fast lookup. * Writers synchronize on breakpointLock, readers just access the arrays. @@ -336,7 +343,7 @@ public static void clearBreakpointsForFile(String file) { rebuildBreakpointBounds(); } updateHasSuspendConditions(); - Log.info("Breakpoints cleared: " + Config.shortenPath(file)); + Log.debug("Breakpoints cleared: " + Config.shortenPath(file)); } /** @@ -352,7 +359,7 @@ public static void clearAllBreakpoints() { bpMaxPathLen = 0; } updateHasSuspendConditions(); - Log.info("Breakpoints cleared: all"); + Log.debug("Breakpoints cleared: all"); } /** @@ -574,10 +581,37 @@ public static boolean isDapClientConnected() { */ public static void setDapClientConnected(boolean connected) { dapClientConnected = connected; + if (!connected) { + onClientDisconnect(); + } updateHasSuspendConditions(); Log.info("DAP client connected: " + connected); } + /** + * Clean up state when DAP client disconnects. + * Resumes any suspended threads to prevent deadlocks. + */ + private static void onClientDisconnect() { + // Resume any suspended threads so they're not stuck forever + resumeAllNativeThreads(); + + // Clear stepping state + steppingThreads.clear(); + + // Clear pending exceptions + pendingExceptions.clear(); + + // Reset exception settings + breakOnUncaughtExceptions = false; + logSystemOutput = false; + + // Note: We intentionally keep breakpoints - they'll be inactive + // since dapClientConnected=false, and will be replaced on next connect + + Log.info("DAP client disconnected - cleanup complete"); + } + /** * Set whether to break on uncaught exceptions. * Called from DapServer when handling setExceptionBreakpoints request. @@ -602,7 +636,6 @@ public static boolean shouldBreakOnUncaughtExceptions() { public static void setLogSystemOutput(boolean enabled) { logSystemOutput = enabled; Log.setLogSystemOutput(enabled); - Log.info("Log system output: " + enabled); } /** diff --git a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java index 4c2db74..6a1b5ee 100644 --- a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java +++ b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java @@ -18,38 +18,34 @@ */ public class ExtensionActivator { private static NativeLuceeVm luceeVm; + private static ClassLoader luceeLoader; + private static ClassLoader extensionLoader; + private static boolean listenerRegistered = false; + private static boolean alreadyActivated = false; /** * Constructor called by Lucee's startup-hook mechanism. * Lucee passes the Config object automatically. + * May be called multiple times (ConfigServer + each ConfigWeb). */ public ExtensionActivator(Config luceeConfig) { - Log.info("Extension activating via startup-hook"); + // Only activate once + if (alreadyActivated) { + return; + } + alreadyActivated = true; - // Get debug port from environment - if not set, debugger is disabled + // Get debug port - if not set, debugger is disabled int debugPort = EnvUtil.getDebuggerPort(); if (debugPort < 0) { - Log.info("Debugger not enabled"); - Log.info("Set LUCEE_DEBUGGER_PORT= to enable"); + Log.info("Debugger disabled - set LUCEE_DEBUGGER_SECRET to enable"); return; } + Log.info("Extension activating"); - // Get classloaders - extension's loader has our classes, Lucee's has core interfaces - ClassLoader extensionLoader = this.getClass().getClassLoader(); - ClassLoader luceeLoader = luceeConfig.getClass().getClassLoader(); - - // Log execution logging status - this determines if breakpoints work - if (EnvUtil.isDebuggerEnabled()) { - Log.info("Execution logging: ENABLED (LUCEE_DEBUGGER_ENABLED=true)"); - } else { - Log.info("Execution logging: DISABLED (set LUCEE_DEBUGGER_ENABLED=true to enable breakpoints)"); - } - - // Register debugger listener with Lucee's DebuggerRegistry - if (!registerNativeDebuggerListener(luceeLoader, extensionLoader)) { - Log.error("Failed to register debugger listener - extension disabled"); - return; - } + // Store classloaders for later listener registration + extensionLoader = this.getClass().getClassLoader(); + luceeLoader = luceeConfig.getClass().getClassLoader(); // Determine filesystem case sensitivity from Lucee's config location String configPath = luceeConfig.getConfigDir().getAbsolutePath(); @@ -65,12 +61,54 @@ public ExtensionActivator(Config luceeConfig) { luceeVm = new NativeLuceeVm(config); // Start DAP server in background thread (createForSocket blocks forever) + // Listener registration is deferred until DAP client connects with secret final int port = debugPort; new Thread(() -> { DapServer.createForSocket(luceeVm, config, "localhost", port); }, "luceedebug-dap-server").start(); - Log.info("DAP server starting on localhost:" + debugPort); + Log.info("DAP server starting on localhost:" + debugPort + " (waiting for client with secret)"); + } + + /** + * Register the debugger listener with Lucee using the client-provided secret. + * Called from DapServer.attach() when client connects. + * Secret is validated on every connection, not just the first one. + * + * @param secret The secret from launch.json + * @return true if registration succeeded + */ + public static synchronized boolean registerListener(String secret) { + if (luceeLoader == null || extensionLoader == null) { + Log.error("Cannot register listener - extension not initialized"); + return false; + } + if (secret == null || secret.trim().isEmpty()) { + Log.error("Cannot register listener - no secret provided"); + return false; + } + // Always validate secret, even if already registered + String expectedSecret = EnvUtil.getDebuggerSecret(); + if (expectedSecret == null || !expectedSecret.equals(secret.trim())) { + Log.error("Invalid secret"); + return false; + } + // Only register with Lucee once + if (!listenerRegistered) { + if (registerNativeDebuggerListener(luceeLoader, extensionLoader, secret.trim())) { + listenerRegistered = true; + } else { + return false; + } + } + return true; + } + + /** + * Check if listener is already registered. + */ + public static boolean isListenerRegistered() { + return listenerRegistered; } /** @@ -157,8 +195,9 @@ private void enableDebuggerExecutionLog(Config luceeConfig, ClassLoader luceeLoa * Register native debugger listener using cross-classloader proxy. * DebuggerRegistry and DebuggerListener are in Lucee's core (luceeLoader). * NativeDebuggerListener is in our extension bundle (extensionLoader). + * Requires the correct secret to register. */ - private boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoader extensionLoader) { + private static boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoader extensionLoader, String secret) { try { // Load Lucee core classes Class registryClass = luceeLoader.loadClass("lucee.runtime.debug.DebuggerRegistry"); @@ -170,6 +209,7 @@ private boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoa "luceedebug.coreinject.NativeDebuggerListener"); // Cache method lookups + final Method getNameMethod = nativeListenerClass.getMethod("getName"); final Method onSuspendMethod = nativeListenerClass.getMethod("onSuspend", pageContextClass, String.class, int.class, String.class); final Method onResumeMethod = nativeListenerClass.getMethod("onResume", pageContextClass); @@ -187,7 +227,8 @@ private boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoa new Class[] { listenerInterface }, (proxy, method, args) -> { switch (method.getName()) { - case "isActive": return isDapClientConnectedMethod.invoke(null); + case "getName": return getNameMethod.invoke(null); + case "isClientConnected": return isDapClientConnectedMethod.invoke(null); case "onSuspend": return onSuspendMethod.invoke(null, args); case "onResume": return onResumeMethod.invoke(null, args); case "shouldSuspend": return shouldSuspendMethod.invoke(null, args); @@ -198,15 +239,23 @@ private boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoa } ); - // Register with Lucee - Method setListener = registryClass.getMethod("setListener", listenerInterface); - setListener.invoke(null, listenerProxy); + // Register with Lucee (requires secret) + Method setListener = registryClass.getMethod("setListener", listenerInterface, String.class); + Boolean success = (Boolean) setListener.invoke(null, listenerProxy, secret); - Log.info("Registered native debugger listener"); - return true; + if (success) { + Log.info("Registered native debugger listener"); + return true; + } else { + Log.error("Debugger registration rejected - secret mismatch"); + return false; + } } catch (ClassNotFoundException e) { Log.info("DebuggerRegistry not found - requires Lucee 7.1+"); return false; + } catch (NoSuchMethodException e) { + Log.error("DebuggerRegistry.setListener(listener, secret) not found - requires updated Lucee 7.1+"); + return false; } catch (Throwable e) { Log.error("Failed to register listener", e); return false; diff --git a/vscode-client/package.json b/vscode-client/package.json index f5f0c87..a95ca22 100644 --- a/vscode-client/package.json +++ b/vscode-client/package.json @@ -185,6 +185,10 @@ "type": "boolean", "default": false, "description": "Show System.out/err in the debug console." + }, + "secret": { + "type": "string", + "description": "Secret that must match LUCEE_DEBUGGER_SECRET environment variable on the server." } } } @@ -195,14 +199,15 @@ "request": "attach", "name": "Attach to server", "hostName": "localhost", + "port": 9999, + "secret": "", "pathTransforms": [ { "idePrefix": "${workspaceFolder}", "serverPrefix": "/app" } ], - "pathSeparator": "auto", - "port": 8000 + "pathSeparator": "auto" } ], "configurationSnippets": [ @@ -214,14 +219,15 @@ "request": "attach", "name": "Attach to server", "hostName": "localhost", + "port": 9999, + "secret": "", "pathTransforms": [ { "idePrefix": "^\"\\${workspaceFolder}\"", "serverPrefix": "/app" } ], - "pathSeparator": "auto", - "port": 8000 + "pathSeparator": "auto" } } ], From bcf09efddd683342e57fd0e400748b8e70bb8a8b Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 5 Dec 2025 17:03:29 +0100 Subject: [PATCH 05/14] LDEV-1402 native debugger pause --- .../src/main/java/luceedebug/DapServer.java | 23 ++++- .../src/main/java/luceedebug/ILuceeVm.java | 13 +++ .../java/luceedebug/coreinject/LuceeVm.java | 10 ++ .../coreinject/NativeDebuggerListener.java | 99 ++++++++++++++++++- .../luceedebug/coreinject/NativeLuceeVm.java | 30 ++++++ 5 files changed, 166 insertions(+), 9 deletions(-) diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index 1cf4e99..b121af6 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -152,6 +152,16 @@ private DapServer(ILuceeVm luceeVm, Config config) { clientProxy_.stopped(event); Log.debug("Sent DAP stopped event for exception, thread=" + javaThreadId + (ex != null ? " exception=" + ex.getClass().getName() : "")); }); + + // Register native pause callback (user clicked pause button) + this.luceeVm_.registerPauseEventCallback(javaThreadId -> { + final int i32_threadID = (int)(long)javaThreadId; + var event = new StoppedEventArguments(); + event.setReason("pause"); + event.setThreadId(i32_threadID); + clientProxy_.stopped(event); + Log.debug("Sent DAP stopped event for pause, thread=" + javaThreadId); + }); } static class DapEntry { @@ -734,12 +744,17 @@ public CompletableFuture exceptionInfo(ExceptionInfoArgum } /** - * Can we disable the UI for this in the client plugin? - * - * @unsupported + * Pause a running thread. In native mode, this is cooperative - the thread + * will pause at the next CFML instrumentation point (next line of CFML code). + * Won't pause threads stuck in pure Java code (JDBC, HTTP, sleep, etc.). */ + @Override public CompletableFuture pause(PauseArguments args) { - // set success false? + long threadId = args.getThreadId(); + Log.info("pause() called for thread " + threadId); + // Thread ID 0 means "pause all threads" - this happens when user clicks + // pause button without a specific thread selected + luceeVm_.pause(threadId); return CompletableFuture.completedFuture(null); } diff --git a/luceedebug/src/main/java/luceedebug/ILuceeVm.java b/luceedebug/src/main/java/luceedebug/ILuceeVm.java index f0a0aab..53f97a7 100644 --- a/luceedebug/src/main/java/luceedebug/ILuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/ILuceeVm.java @@ -66,6 +66,13 @@ public static BreakpointsChangedEvent justChanges(IBreakpoint[] changes) { public void stepOver(long threadID); public void stepOut(long threadID); + /** + * Request a thread to pause at the next CFML line. + * In native mode, this is cooperative - the thread pauses at the next instrumentation point. + * @param threadID The thread ID to pause + */ + public void pause(long threadID); + public void clearAllBreakpoints(); @@ -92,6 +99,12 @@ public static BreakpointsChangedEvent justChanges(IBreakpoint[] changes) { */ public void registerExceptionEventCallback(java.util.function.Consumer cb); + /** + * Register callback for pause events (native mode only). + * Called with Java thread ID when a thread stops due to user pause request. + */ + public void registerPauseEventCallback(java.util.function.Consumer cb); + /** * Get the exception that caused a thread to suspend. * Returns null if the thread is not suspended due to an exception. diff --git a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java index bf7e5a7..3e0b305 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java @@ -1185,6 +1185,16 @@ public void registerExceptionEventCallback(Consumer cb) { // no-op for JDWP mode } + public void registerPauseEventCallback(Consumer cb) { + // no-op for JDWP mode - could use ThreadReference.suspend() but not implemented + } + + public void pause(long threadID) { + // TODO: Could use ThreadReference.suspend() for JDWP mode + // For now, just log that it's not supported + System.out.println("[luceedebug] pause() not implemented for JDWP mode"); + } + public Throwable getExceptionForThread(long threadId) { // JDWP mode doesn't use NativeDebuggerListener for exceptions return null; diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java index 48cfb4e..5a4f5e1 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java @@ -152,13 +152,31 @@ public SuspendLocation( String file, int line, String label, Throwable exception */ private static final ConcurrentHashMap steppingThreads = new ConcurrentHashMap<>(); + /** + * Threads that have been requested to pause. + * Checked in shouldSuspend() - when a thread is in this set it will pause at the next CFML line. + */ + private static final ConcurrentHashMap threadsToPause = new ConcurrentHashMap<>(); + + /** + * Threads that suspended due to a pause request. + * Set in shouldSuspend() when consuming a pause request, consumed in onSuspend(). + */ + private static final ConcurrentHashMap pausedThreads = new ConcurrentHashMap<>(); + + /** + * Callback to notify when a thread pauses due to user-initiated pause request. + * Called with Java thread ID. Used for "pause" stop reason in DAP. + */ + private static volatile Consumer onNativePauseCallback = null; + /** * Update hasSuspendConditions flag based on current state. - * Called whenever breakpoints, exception settings, or stepping state changes. + * Called whenever breakpoints, exception settings, stepping, or pause state changes. */ private static void updateHasSuspendConditions() { hasSuspendConditions = dapClientConnected && - (bpLines.length > 0 || breakOnUncaughtExceptions || !steppingThreads.isEmpty()); + (bpLines.length > 0 || breakOnUncaughtExceptions || !steppingThreads.isEmpty() || !threadsToPause.isEmpty()); } /** @@ -231,6 +249,14 @@ public static void setOnNativeExceptionCallback(Consumer callback) { onNativeExceptionCallback = callback; } + /** + * Set the callback for native pause events. + * LuceeVm should register this to receive notifications and send DAP pause events. + */ + public static void setOnNativePauseCallback(Consumer callback) { + onNativePauseCallback = callback; + } + /** * Add a breakpoint at the given file and line. */ @@ -461,6 +487,49 @@ public static void resumeAllNativeThreads() { } } + // ========== Pause methods ========== + + /** + * Virtual thread ID for "All Threads" - used when no specific thread is targeted. + * Thread ID 0 means "all threads" in DAP, and we also use 1 as an alias for the + * "All CFML Threads" entry shown in the VSCode threads panel. + * Must match ALL_THREADS_VIRTUAL_ID in NativeLuceeVm. + */ + private static final long ALL_THREADS_VIRTUAL_ID = 1; + + /** + * Request a thread to pause at the next CFML line. + * The thread will suspend cooperatively when it hits the next instrumentation point. + * @param threadId The Java thread ID to pause, or 0/1 to pause all threads + */ + public static void requestPause(long threadId) { + if (threadId == 0 || threadId == ALL_THREADS_VIRTUAL_ID) { + // Pause all - we set a flag that shouldSuspend() will check for any thread + threadsToPause.put(0L, Boolean.TRUE); + } else { + threadsToPause.put(threadId, Boolean.TRUE); + } + updateHasSuspendConditions(); + Log.info("Pause requested for thread: " + (threadId == 0 || threadId == ALL_THREADS_VIRTUAL_ID ? "all" : threadId)); + } + + /** + * Check if a thread has a pending pause request. + * Clears the request after checking (pause is consumed). + */ + private static boolean consumePauseRequest(long threadId) { + // Check for specific thread first, then "pause all" + if (threadsToPause.remove(threadId) != null) { + updateHasSuspendConditions(); + return true; + } + if (threadsToPause.remove(0L) != null) { + updateHasSuspendConditions(); + return true; + } + return false; + } + // ========== Stepping methods ========== /** @@ -513,7 +582,10 @@ public static void onSuspend(PageContext pc, String file, int line, String label StepState stepState = steppingThreads.remove(threadId); boolean wasStepping = (stepState != null); - // Check if we hit a breakpoint (breakpoint wins over step) + // Check if we paused due to user pause request + boolean wasPaused = pausedThreads.remove(threadId) != null; + + // Check if we hit a breakpoint (breakpoint wins over step/pause) boolean hitBreakpoint = hasBreakpoint(file, line); // Check if there's a pending exception for this thread (from onException) @@ -527,7 +599,7 @@ public static void onSuspend(PageContext pc, String file, int line, String label // Include the exception if we're suspending due to one suspendLocations.put(threadId, new SuspendLocation(file, line, label, pendingException)); - // Fire appropriate callback - exception takes precedence, then breakpoint, then step + // Fire appropriate callback - exception takes precedence, then breakpoint, then pause, then step if (pendingException != null) { // Stopped due to uncaught exception Consumer callback = onNativeExceptionCallback; @@ -540,6 +612,12 @@ public static void onSuspend(PageContext pc, String file, int line, String label if (callback != null) { callback.accept(threadId, null); } + } else if (wasPaused) { + // Stopped due to user pause request + Consumer callback = onNativePauseCallback; + if (callback != null) { + callback.accept(threadId); + } } else if (wasStepping) { // Stopped due to stepping Consumer callback = onNativeStepCallback; @@ -599,6 +677,10 @@ private static void onClientDisconnect() { // Clear stepping state steppingThreads.clear(); + // Clear pause state + threadsToPause.clear(); + pausedThreads.clear(); + // Clear pending exceptions pendingExceptions.clear(); @@ -726,8 +808,15 @@ public static boolean shouldSuspend(PageContext pc, String file, int line) { } } - // Check stepping state long threadId = Thread.currentThread().getId(); + + // Check for pause request (user clicked pause button) + if (consumePauseRequest(threadId)) { + pausedThreads.put(threadId, Boolean.TRUE); + return true; + } + + // Check stepping state StepState stepState = steppingThreads.get(threadId); if (stepState == null) { return false; diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java index ca76b7f..0922548 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java @@ -33,6 +33,7 @@ public class NativeLuceeVm implements ILuceeVm { private BiConsumer breakpointEventCallback = null; private BiConsumer nativeBreakpointEventCallback = null; private Consumer exceptionEventCallback = null; + private Consumer pauseEventCallback = null; private Consumer breakpointsChangedCallback = null; private AtomicInteger breakpointID = new AtomicInteger(); @@ -74,6 +75,13 @@ public NativeLuceeVm(Config config) { exceptionEventCallback.accept(javaThreadId); } }); + + // Register native pause callback + NativeDebuggerListener.setOnNativePauseCallback(javaThreadId -> { + if (pauseEventCallback != null) { + pauseEventCallback.accept(javaThreadId); + } + }); } private DapBreakpointID nextDapBreakpointID() { @@ -105,6 +113,11 @@ public void registerBreakpointsChangedCallback(Consumer // ========== Thread operations ========== + // Virtual thread ID for "All Threads" - used when no specific thread is targeted + // Thread ID 0 means "all threads" in DAP, but VSCode needs a visible thread to send pause + // We use 1 as a safe ID that won't conflict with real Java thread IDs (which start much higher) + private static final long ALL_THREADS_VIRTUAL_ID = 1; + @Override public ThreadInfo[] getThreadListing() { var result = new ArrayList(); @@ -162,6 +175,13 @@ public ThreadInfo[] getThreadListing() { Log.error("Error getting thread listing", e); } + // Always show a virtual "All Threads" entry so VSCode has something to target with pause + // This allows pause to work even when no specific request thread is visible + // When paused with this ID, all CFML threads will pause at their next instrumentation point + if (!seenThreadIds.contains(ALL_THREADS_VIRTUAL_ID)) { + result.add(0, new ThreadInfo(ALL_THREADS_VIRTUAL_ID, "All CFML Threads")); + } + Log.debug("Thread listing: " + result.size() + " threads"); return result.toArray(new ThreadInfo[0]); } @@ -351,6 +371,16 @@ public void registerExceptionEventCallback(Consumer cb) { exceptionEventCallback = cb; } + @Override + public void registerPauseEventCallback(Consumer cb) { + pauseEventCallback = cb; + } + + @Override + public void pause(long threadID) { + NativeDebuggerListener.requestPause(threadID); + } + @Override public Throwable getExceptionForThread(long threadId) { NativeDebuggerListener.SuspendLocation loc = NativeDebuggerListener.getSuspendLocation(threadId); From a0842aaff337e8bf6573f2adcf3a63cc449172fa Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 5 Dec 2025 19:39:46 +0100 Subject: [PATCH 06/14] LDEV-1402 add getExecutableLines support for validating breakpoints --- .../src/main/java/luceedebug/DapServer.java | 32 ++++ .../coreinject/NativeDebuggerListener.java | 141 ++++++++++++++++++ .../luceedebug/coreinject/NativeLuceeVm.java | 31 +++- 3 files changed, 201 insertions(+), 3 deletions(-) diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index b121af6..de5abe4 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -326,6 +326,7 @@ public CompletableFuture initialize(InitializeRequestArguments arg uncaughtFilter.setDescription("Break when an exception is not caught by a try/catch block"); c.setExceptionBreakpointFilters(new ExceptionBreakpointsFilter[] { uncaughtFilter }); c.setSupportsExceptionInfoRequest(true); + c.setSupportsBreakpointLocationsRequest(true); Log.debug("Returning capabilities with exceptionBreakpointFilters: " + java.util.Arrays.toString(c.getExceptionBreakpointFilters())); return CompletableFuture.completedFuture(c); @@ -671,6 +672,37 @@ private Breakpoint map_cfBreakpoint_to_lsp4jBreakpoint(IBreakpoint cfBreakpoint) return bp; } + @Override + public CompletableFuture breakpointLocations(BreakpointLocationsArguments args) { + var response = new BreakpointLocationsResponse(); + + // Only works in native mode with NativeLuceeVm + if (!(luceeVm_ instanceof luceedebug.coreinject.NativeLuceeVm)) { + response.setBreakpoints(new BreakpointLocation[0]); + return CompletableFuture.completedFuture(response); + } + + var nativeVm = (luceedebug.coreinject.NativeLuceeVm) luceeVm_; + String serverPath = applyPathTransformsIdeToCf(args.getSource().getPath()); + int[] executableLines = nativeVm.getExecutableLines(serverPath); + + // Filter to requested line range + int startLine = args.getLine(); + int endLine = args.getEndLine() != null ? args.getEndLine() : startLine; + + var locations = new java.util.ArrayList(); + for (int line : executableLines) { + if (line >= startLine && line <= endLine) { + var loc = new BreakpointLocation(); + loc.setLine(line); + locations.add(loc); + } + } + + response.setBreakpoints(locations.toArray(new BreakpointLocation[0])); + return CompletableFuture.completedFuture(response); + } + /** * We don't really support this, but not sure how to say that; there doesn't seem to be a "supports exception breakpoints" * flag in the init response? vscode always sends this? diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java index 5a4f5e1..22d92ba 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java @@ -876,4 +876,145 @@ public static boolean hasBreakpoint(String file, int line) { } return false; } + + /** + * Get executable line numbers for a file. + * Triggers compilation if the file hasn't been compiled yet. + * + * @param absolutePath The absolute file path + * @return Array of line numbers where breakpoints can be set, or empty array if file has errors + */ + public static int[] getExecutableLines(String absolutePath) { + // Try suspended threads first, then any active PageContext, then create temp + PageContext pc = getAnyPageContext(); + if (pc == null) { + pc = getAnyActivePageContext(); + } + if (pc == null) { + pc = createTemporaryPageContext(); + } + if (pc == null) { + Log.debug("getExecutableLines: no PageContext available"); + return new int[0]; + } + + try { + // Get the webroot from the PageContext's servlet context + Object servletContext = pc.getClass().getMethod("getServletContext").invoke(pc); + String webroot = (String) servletContext.getClass().getMethod("getRealPath", String.class).invoke(servletContext, "/"); + + // Convert absolute path to relative path by stripping webroot prefix + String normalizedAbsPath = absolutePath.replace('\\', '/').toLowerCase(); + String normalizedWebroot = webroot.replace('\\', '/').toLowerCase(); + if (!normalizedWebroot.endsWith("/")) normalizedWebroot += "/"; + + String relativePath; + if (normalizedAbsPath.startsWith(normalizedWebroot)) { + relativePath = "/" + absolutePath.substring(webroot.length()).replace('\\', '/'); + // Handle case where webroot didn't have trailing slash + if (relativePath.startsWith("//")) relativePath = relativePath.substring(1); + } else { + // File is outside webroot - can't load it via PageSource + Log.debug("getExecutableLines: file outside webroot: " + absolutePath); + return new int[0]; + } + + // Use reflection for PageContextImpl.getPageSource() - core class not visible to OSGi bundle + java.lang.reflect.Method getPageSourceMethod = pc.getClass().getMethod("getPageSource", String.class); + Object ps = getPageSourceMethod.invoke(pc, relativePath); + if (ps == null) { + Log.debug("getExecutableLines: no PageSource for " + absolutePath); + return new int[0]; + } + + // Load/compile the page via PageSource.loadPage(PageContext, boolean) + java.lang.reflect.Method loadPageMethod = ps.getClass().getMethod("loadPage", PageContext.class, boolean.class); + Object page = loadPageMethod.invoke(ps, pc, false); + if (page == null) { + Log.debug("getExecutableLines: failed to load page " + absolutePath); + return new int[0]; + } + + // Get executable lines from compiled Page class + java.lang.reflect.Method getExecLinesMethod = page.getClass().getMethod("getExecutableLines"); + int[] lines = (int[]) getExecLinesMethod.invoke(page); + return lines; + } catch (NoSuchMethodException e) { + // Method doesn't exist - Lucee version without this feature + Log.debug("getExecutableLines: method not found (old Lucee version?)"); + return new int[0]; + } catch (java.lang.reflect.InvocationTargetException e) { + // Unwrap the real exception from reflection + Throwable cause = e.getCause(); + Log.debug("getExecutableLines failed for " + absolutePath + ": " + + (cause != null ? cause.getClass().getName() + ": " + cause.getMessage() : e.getMessage())); + return new int[0]; + } catch (Exception e) { + Log.debug("getExecutableLines failed for " + absolutePath + ": " + e.getClass().getName() + ": " + e.getMessage()); + return new int[0]; + } + } + + /** + * Get any active PageContext from running requests. + * Used when no thread is suspended but we need a PageContext for compilation. + */ + private static PageContext getAnyActivePageContext() { + try { + Object engine = lucee.loader.engine.CFMLEngineFactory.getInstance(); + java.lang.reflect.Method getEngineMethod = engine.getClass().getMethod("getEngine"); + Object engineImpl = getEngineMethod.invoke(engine); + + java.lang.reflect.Method getFactoriesMethod = engineImpl.getClass().getMethod("getCFMLFactories"); + @SuppressWarnings("unchecked") + java.util.Map factoriesMap = (java.util.Map) getFactoriesMethod.invoke(engineImpl); + + for (Object factory : factoriesMap.values()) { + try { + java.lang.reflect.Method getActiveMethod = factory.getClass().getMethod("getActivePageContexts"); + @SuppressWarnings("unchecked") + java.util.Map activeContexts = (java.util.Map) getActiveMethod.invoke(factory); + + for (Object pc : activeContexts.values()) { + if (pc instanceof PageContext) { + return (PageContext) pc; + } + } + } catch (Exception e) { + // Skip this factory + } + } + } catch (Exception e) { + Log.debug("getAnyActivePageContext failed: " + e.getMessage()); + } + return null; + } + + /** + * Create a temporary PageContext for compilation when no active request exists. + * Uses CFMLEngineFactory.getInstance().createPageContext() like LSPUtil does. + */ + private static PageContext createTemporaryPageContext() { + try { + Object engine = lucee.loader.engine.CFMLEngineFactory.getInstance(); + + // Find and call createPageContext(File, String, String, String, Cookie[], Map, Map, Map, OutputStream, long, boolean) + for (java.lang.reflect.Method m : engine.getClass().getMethods()) { + if (m.getName().equals("createPageContext") && m.getParameterCount() == 11) { + Class[] params = m.getParameterTypes(); + if (params[0].getName().equals("java.io.File")) { + java.io.File contextRoot = new java.io.File("."); + java.io.OutputStream devNull = new java.io.ByteArrayOutputStream(); + Object pc = m.invoke(engine, contextRoot, "localhost", "/", "", null, null, null, null, devNull, -1L, false); + if (pc instanceof PageContext) { + return (PageContext) pc; + } + } + } + } + } catch (Exception e) { + Log.debug("createTemporaryPageContext failed: " + e.getMessage()); + } + return null; + } } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java index 0922548..5d10a3e 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java @@ -267,13 +267,27 @@ public IBreakpoint[] bindBreakpoints(RawIdePath idePath, CanonicalServerAbsPath // Clear existing native breakpoints for this file NativeDebuggerListener.clearBreakpointsForFile(serverPath.get()); + // Get executable lines to validate breakpoints + int[] executableLines = getExecutableLines(serverPath.get()); + java.util.Set validLines = new java.util.HashSet<>(); + for (int line : executableLines) { + validLines.add(line); + } + // Add native breakpoints with optional conditions IBreakpoint[] result = new Breakpoint[lines.length]; for (int i = 0; i < lines.length; i++) { String condition = (exprs != null && i < exprs.length) ? exprs[i] : null; - NativeDebuggerListener.addBreakpoint(serverPath.get(), lines[i], condition); - // Native breakpoints are always "bound" - no class loading dependency - result[i] = Breakpoint.Bound(lines[i], nextDapBreakpointID()); + int requestedLine = lines[i]; + + if (validLines.contains(requestedLine)) { + // Valid executable line - add breakpoint and mark as bound + NativeDebuggerListener.addBreakpoint(serverPath.get(), requestedLine, condition); + result[i] = Breakpoint.Bound(requestedLine, nextDapBreakpointID()); + } else { + // Not an executable line - mark as unbound (unverified) + result[i] = Breakpoint.Unbound(requestedLine, nextDapBreakpointID()); + } } return result; @@ -386,4 +400,15 @@ public Throwable getExceptionForThread(long threadId) { NativeDebuggerListener.SuspendLocation loc = NativeDebuggerListener.getSuspendLocation(threadId); return loc != null ? loc.exception : null; } + + /** + * Get executable line numbers for a file. + * Used by DAP breakpointLocations request. + * + * @param serverPath The server-side absolute file path + * @return Array of line numbers where breakpoints can be set + */ + public int[] getExecutableLines(String serverPath) { + return NativeDebuggerListener.getExecutableLines(serverPath); + } } From 4f6753d8a8a65b7b69bf4f4a159003da7c4c53fe Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sat, 6 Dec 2025 13:41:15 +0100 Subject: [PATCH 07/14] LDEV-1402 setVariable support --- .../src/main/java/luceedebug/DapServer.java | 54 ++++++++ .../src/main/java/luceedebug/ILuceeVm.java | 10 ++ .../coreinject/CfValueDebuggerBridge.java | 79 ++++++++---- .../java/luceedebug/coreinject/LuceeVm.java | 6 + .../coreinject/NativeDebuggerListener.java | 115 ++++++++++++------ .../luceedebug/coreinject/NativeLuceeVm.java | 90 +++++++++++++- .../luceedebug/coreinject/ValTracker.java | 89 +++++++++++++- .../coreinject/frame/NativeDebugFrame.java | 22 +++- 8 files changed, 402 insertions(+), 63 deletions(-) diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index de5abe4..4d991a8 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -327,6 +327,7 @@ public CompletableFuture initialize(InitializeRequestArguments arg c.setExceptionBreakpointFilters(new ExceptionBreakpointsFilter[] { uncaughtFilter }); c.setSupportsExceptionInfoRequest(true); c.setSupportsBreakpointLocationsRequest(true); + c.setSupportsSetVariable(true); Log.debug("Returning capabilities with exceptionBreakpointFilters: " + java.util.Arrays.toString(c.getExceptionBreakpointFilters())); return CompletableFuture.completedFuture(c); @@ -631,6 +632,59 @@ public CompletableFuture variables(VariablesArguments args) { return CompletableFuture.completedFuture(result); } + @Override + public CompletableFuture setVariable(SetVariableArguments args) { + Log.debug("setVariable() called: variablesReference=" + args.getVariablesReference() + ", name=" + args.getName() + ", value=" + args.getValue()); + + // Don't allow setting variables if secret wasn't validated + if (!secretValidated) { + var exceptionalResult = new CompletableFuture(); + var error = new ResponseError(ResponseErrorCode.InvalidRequest, "Not authorized - secret not validated", null); + exceptionalResult.completeExceptionally(new ResponseErrorException(error)); + return exceptionalResult; + } + + return luceeVm_ + .setVariable(args.getVariablesReference(), args.getName(), args.getValue(), 0) + .collapse( + errMsg -> { + Log.info("setVariable() - error: " + errMsg); + var exceptionalResult = new CompletableFuture(); + var error = new ResponseError(ResponseErrorCode.InternalError, errMsg, null); + exceptionalResult.completeExceptionally(new ResponseErrorException(error)); + return exceptionalResult; + }, + someResult -> { + return someResult.collapse( + bridgeObj -> { + // Complex object returned + IDebugEntity value = bridgeObj.maybeNull_asValue(args.getName()); + var response = new SetVariableResponse(); + if (value == null) { + response.setValue(""); + response.setVariablesReference(0); + } else { + response.setValue(value.getValue()); + response.setVariablesReference((int) value.getVariablesReference()); + response.setNamedVariables(bridgeObj.getNamedVariablesCount()); + response.setIndexedVariables(bridgeObj.getIndexedVariablesCount()); + } + Log.debug("setVariable() - success (object): value=" + response.getValue() + ", ref=" + response.getVariablesReference()); + return CompletableFuture.completedFuture(response); + }, + stringValue -> { + // Simple value returned + var response = new SetVariableResponse(); + response.setValue(stringValue); + response.setVariablesReference(0); + Log.debug("setVariable() - success (simple): value=" + stringValue); + return CompletableFuture.completedFuture(response); + } + ); + } + ); + } + @Override public CompletableFuture setBreakpoints(SetBreakpointsArguments args) { // Don't accept breakpoints if secret wasn't validated diff --git a/luceedebug/src/main/java/luceedebug/ILuceeVm.java b/luceedebug/src/main/java/luceedebug/ILuceeVm.java index 53f97a7..329d5c3 100644 --- a/luceedebug/src/main/java/luceedebug/ILuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/ILuceeVm.java @@ -93,6 +93,16 @@ public static BreakpointsChangedEvent justChanges(IBreakpoint[] changes) { public Either> evaluate(int frameID, String expr); + /** + * Set a variable value. + * @param variablesReference The parent container's variablesReference + * @param name The variable name within the container + * @param value The new value as a string expression + * @param frameId The frame ID for context (used to get PageContext) + * @return Either an error message (Left), or the new value as ICfValueDebuggerBridge or String (Right) + */ + public Either> setVariable(long variablesReference, String name, String value, long frameId); + /** * Register callback for exception events (native mode only). * Called with Java thread ID when a thread stops due to an uncaught exception. diff --git a/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java b/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java index 25fced4..743830a 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java @@ -68,34 +68,57 @@ public Scope(Map scopelike) { * @maybeNull_which --> null means "any type" */ public static IDebugEntity[] getAsDebugEntity(Frame frame, Object obj, IDebugEntity.DebugEntityType maybeNull_which) { - return getAsDebugEntity(frame.valTracker, obj, maybeNull_which); + return getAsDebugEntity(frame.valTracker, obj, maybeNull_which, null); } public static IDebugEntity[] getAsDebugEntity(ValTracker valTracker, Object obj, IDebugEntity.DebugEntityType maybeNull_which) { + return getAsDebugEntity(valTracker, obj, maybeNull_which, null); + } + + /** + * Get debug entities for an object's children. + * @param valTracker The value tracker + * @param obj The parent object to expand + * @param maybeNull_which Filter for named/indexed variables, or null for all + * @param parentPath The variable path of the parent (e.g., "local.foo"), or null if not tracked + */ + public static IDebugEntity[] getAsDebugEntity(ValTracker valTracker, Object obj, IDebugEntity.DebugEntityType maybeNull_which, String parentPath) { + return getAsDebugEntity(valTracker, obj, maybeNull_which, parentPath, null); + } + + /** + * Get debug entities for an object's children. + * @param valTracker The value tracker + * @param obj The parent object to expand + * @param maybeNull_which Filter for named/indexed variables, or null for all + * @param parentPath The variable path of the parent (e.g., "local.foo"), or null if not tracked + * @param frameId The frame ID for setVariable support, or null if not tracked + */ + public static IDebugEntity[] getAsDebugEntity(ValTracker valTracker, Object obj, IDebugEntity.DebugEntityType maybeNull_which, String parentPath, Long frameId) { final boolean namedOK = maybeNull_which == null || maybeNull_which == IDebugEntity.DebugEntityType.NAMED; final boolean indexedOK = maybeNull_which == null || maybeNull_which == IDebugEntity.DebugEntityType.INDEXED; if (obj instanceof MarkerTrait.Scope && namedOK) { @SuppressWarnings("unchecked") var m = (Map)(((MarkerTrait.Scope)obj).scopelike); - return getAsMaplike(valTracker, m); + return getAsMaplike(valTracker, m, parentPath, frameId); } else if (obj instanceof Map && namedOK) { if (obj instanceof Component) { return new IDebugEntity[] { - maybeNull_asValue(valTracker, "this", obj, true, true), - maybeNull_asValue(valTracker, "variables", ((Component)obj).getComponentScope()), - maybeNull_asValue(valTracker, "static", ((Component)obj).staticScope()) + maybeNull_asValue(valTracker, "this", obj, true, true, parentPath, frameId), + maybeNull_asValue(valTracker, "variables", ((Component)obj).getComponentScope(), parentPath, frameId), + maybeNull_asValue(valTracker, "static", ((Component)obj).staticScope(), parentPath, frameId) }; } else { @SuppressWarnings("unchecked") var m = (Map)obj; - return getAsMaplike(valTracker, m); + return getAsMaplike(valTracker, m, parentPath, frameId); } } else if (obj instanceof Array && indexedOK) { - return getAsCfArray(valTracker, (Array)obj); + return getAsCfArray(valTracker, (Array)obj, parentPath, frameId); } else { return new IDebugEntity[0]; @@ -138,16 +161,16 @@ private static boolean isInstanceOf(Object obj, String className) { return false; } - private static IDebugEntity[] getAsMaplike(ValTracker valTracker, Map map) { + private static IDebugEntity[] getAsMaplike(ValTracker valTracker, Map map, String parentPath, Long frameId) { ArrayList results = new ArrayList<>(); - + Set> entries = map.entrySet(); // We had been showing member functions on component instances, but it's really just noise. Maybe this could be a configurable option. final var skipNoisyComponentFunctions = true; - + for (Map.Entry entry : entries) { - IDebugEntity val = maybeNull_asValue(valTracker, entry.getKey(), entry.getValue(), skipNoisyComponentFunctions, false); + IDebugEntity val = maybeNull_asValue(valTracker, entry.getKey(), entry.getValue(), skipNoisyComponentFunctions, false, parentPath, frameId); if (val != null) { results.add(val); } @@ -160,17 +183,17 @@ private static IDebugEntity[] getAsMaplike(ValTracker valTracker, Map result = new ArrayList<>(); // cf 1-indexed for (int i = 1; i <= array.size(); ++i) { - IDebugEntity val = maybeNull_asValue(valTracker, Integer.toString(i), array.get(i, null)); + IDebugEntity val = maybeNull_asValue(valTracker, Integer.toString(i), array.get(i, null), parentPath, frameId); if (val != null) { result.add(val); } @@ -180,7 +203,7 @@ private static IDebugEntity[] getAsCfArray(ValTracker valTracker, Array array) { } public IDebugEntity maybeNull_asValue(String name) { - return maybeNull_asValue(frame.valTracker, name, obj, true, false); + return maybeNull_asValue(frame.valTracker, name, obj, true, false, null, null); } /** @@ -189,21 +212,27 @@ public IDebugEntity maybeNull_asValue(String name) { * which is used to cut down on noise from CFC getters/setters/member-functions which aren't too useful for debugging. * Maybe such things should be optionally included as per some configuration. */ - private static IDebugEntity maybeNull_asValue(ValTracker valTracker, String name, Object obj) { - return maybeNull_asValue(valTracker, name, obj, true, false); + private static IDebugEntity maybeNull_asValue(ValTracker valTracker, String name, Object obj, String parentPath, Long frameId) { + return maybeNull_asValue(valTracker, name, obj, true, false, parentPath, frameId); } /** * @markDiscoveredComponentsAsIterableThisRef if true, a Component will be marked as if it were any normal Map. This drives discovery of variables; * showing the "top level" of a component we want to show its "inner scopes" (this, variables, and static) + * @param parentPath The variable path of the parent container (e.g., "local"), or null if not tracked + * @param frameId The frame ID for setVariable support, or null if not tracked */ private static IDebugEntity maybeNull_asValue( ValTracker valTracker, String name, Object obj, boolean skipNoisyComponentFunctions, - boolean treatDiscoveredComponentsAsScopes + boolean treatDiscoveredComponentsAsScopes, + String parentPath, + Long frameId ) { + // Build the full path for this variable + String childPath = (parentPath != null) ? parentPath + "." + name : null; DebugEntity val = new DebugEntity(); val.name = name; @@ -225,7 +254,7 @@ else if (obj instanceof java.util.Date) { else if (obj instanceof Array) { int len = ((Array)obj).size(); val.value = "Array (" + len + ")"; - val.variablesReference = valTracker.idempotentRegisterObject(obj).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(obj, childPath, frameId).id; } else if ( /* @@ -256,13 +285,13 @@ else if (isInstanceOf(obj, "lucee.runtime.type.QueryImpl")) { pin(queryAsArrayOfStructs); - val.variablesReference = valTracker.idempotentRegisterObject(queryAsArrayOfStructs).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(queryAsArrayOfStructs, childPath, frameId).id; } catch (Throwable e) { // Fall back to generic display try { val.value = obj.getClass().toString(); - val.variablesReference = valTracker.idempotentRegisterObject(obj).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(obj, childPath, frameId).id; } catch (Throwable x) { val.value = " (no string representation available)"; @@ -276,22 +305,22 @@ else if (obj instanceof Map) { if (treatDiscoveredComponentsAsScopes) { var v = new MarkerTrait.Scope((Component)obj); ((ComponentScopeMarkerTraitShim)obj).__luceedebug__pinComponentScopeMarkerTrait(v); - val.variablesReference = valTracker.idempotentRegisterObject(v).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(v, childPath, frameId).id; } else { - val.variablesReference = valTracker.idempotentRegisterObject(obj).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(obj, childPath, frameId).id; } } else { int len = ((Map)obj).size(); val.value = "{} (" + len + " members)"; - val.variablesReference = valTracker.idempotentRegisterObject(obj).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(obj, childPath, frameId).id; } } else { try { val.value = obj.getClass().toString(); - val.variablesReference = valTracker.idempotentRegisterObject(obj).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(obj, childPath, frameId).id; } catch (Throwable x) { val.value = " (no string representation available)"; diff --git a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java index 3e0b305..d8ecdc0 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java @@ -1180,6 +1180,12 @@ public Either> evaluate(int frame return GlobalIDebugManagerHolder.debugManager.evaluate((Long)(long)frameID, expr); } + public Either> setVariable(long variablesReference, String name, String value, long frameId) { + // setVariable not yet implemented for JDWP mode + // Would need to use DebugManager to evaluate and set the value + return Either.Left("setVariable not yet supported in JDWP mode - use native debugger mode instead"); + } + // Not used in JDWP mode - exception handling uses JDWP events public void registerExceptionEventCallback(Consumer cb) { // no-op for JDWP mode diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java index 22d92ba..64a6ae0 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java @@ -96,6 +96,20 @@ public SuspendLocation( String file, int line, String label, Throwable exception */ private static final ConcurrentHashMap pendingExceptions = new ConcurrentHashMap<>(); + /** + * Cache for executable lines - keyed by file path, stores compileTime and lines. + * Avoids re-decoding bitmap on every breakpoint set if file hasn't changed. + */ + private static class CachedExecutableLines { + final long compileTime; + final int[] lines; + CachedExecutableLines( long compileTime, int[] lines ) { + this.compileTime = compileTime; + this.lines = lines; + } + } + private static final ConcurrentHashMap executableLinesCache = new ConcurrentHashMap<>(); + /** * Callback to notify LuceeVm when a thread suspends via native breakpoint. * Called with Java thread ID and optional label. Used for "breakpoint" stop reason in DAP. @@ -880,77 +894,110 @@ public static boolean hasBreakpoint(String file, int line) { /** * Get executable line numbers for a file. * Triggers compilation if the file hasn't been compiled yet. + * Uses caching based on compileTime to avoid repeated bitmap decoding. * * @param absolutePath The absolute file path * @return Array of line numbers where breakpoints can be set, or empty array if file has errors */ - public static int[] getExecutableLines(String absolutePath) { + public static int[] getExecutableLines( String absolutePath ) { // Try suspended threads first, then any active PageContext, then create temp PageContext pc = getAnyPageContext(); - if (pc == null) { + if ( pc == null ) { pc = getAnyActivePageContext(); } - if (pc == null) { + if ( pc == null ) { pc = createTemporaryPageContext(); } - if (pc == null) { - Log.debug("getExecutableLines: no PageContext available"); + if ( pc == null ) { + Log.debug( "getExecutableLines: no PageContext available" ); return new int[0]; } try { // Get the webroot from the PageContext's servlet context - Object servletContext = pc.getClass().getMethod("getServletContext").invoke(pc); - String webroot = (String) servletContext.getClass().getMethod("getRealPath", String.class).invoke(servletContext, "/"); + Object servletContext = pc.getClass().getMethod( "getServletContext" ).invoke( pc ); + String webroot = (String) servletContext.getClass().getMethod( "getRealPath", String.class ).invoke( servletContext, "/" ); // Convert absolute path to relative path by stripping webroot prefix - String normalizedAbsPath = absolutePath.replace('\\', '/').toLowerCase(); - String normalizedWebroot = webroot.replace('\\', '/').toLowerCase(); - if (!normalizedWebroot.endsWith("/")) normalizedWebroot += "/"; + String normalizedAbsPath = absolutePath.replace( '\\', '/' ).toLowerCase(); + String normalizedWebroot = webroot.replace( '\\', '/' ).toLowerCase(); + if ( !normalizedWebroot.endsWith( "/" ) ) normalizedWebroot += "/"; String relativePath; - if (normalizedAbsPath.startsWith(normalizedWebroot)) { - relativePath = "/" + absolutePath.substring(webroot.length()).replace('\\', '/'); + if ( normalizedAbsPath.startsWith( normalizedWebroot ) ) { + relativePath = "/" + absolutePath.substring( webroot.length() ).replace( '\\', '/' ); // Handle case where webroot didn't have trailing slash - if (relativePath.startsWith("//")) relativePath = relativePath.substring(1); - } else { + if ( relativePath.startsWith( "//" ) ) relativePath = relativePath.substring( 1 ); + } + else { // File is outside webroot - can't load it via PageSource - Log.debug("getExecutableLines: file outside webroot: " + absolutePath); + Log.debug( "getExecutableLines: file outside webroot: " + absolutePath ); return new int[0]; } // Use reflection for PageContextImpl.getPageSource() - core class not visible to OSGi bundle - java.lang.reflect.Method getPageSourceMethod = pc.getClass().getMethod("getPageSource", String.class); - Object ps = getPageSourceMethod.invoke(pc, relativePath); - if (ps == null) { - Log.debug("getExecutableLines: no PageSource for " + absolutePath); + java.lang.reflect.Method getPageSourceMethod = pc.getClass().getMethod( "getPageSource", String.class ); + Object ps = getPageSourceMethod.invoke( pc, relativePath ); + if ( ps == null ) { + Log.debug( "getExecutableLines: no PageSource for " + absolutePath ); return new int[0]; } // Load/compile the page via PageSource.loadPage(PageContext, boolean) - java.lang.reflect.Method loadPageMethod = ps.getClass().getMethod("loadPage", PageContext.class, boolean.class); - Object page = loadPageMethod.invoke(ps, pc, false); - if (page == null) { - Log.debug("getExecutableLines: failed to load page " + absolutePath); + java.lang.reflect.Method loadPageMethod = ps.getClass().getMethod( "loadPage", PageContext.class, boolean.class ); + Object page = loadPageMethod.invoke( ps, pc, false ); + if ( page == null ) { + Log.debug( "getExecutableLines: failed to load page " + absolutePath ); return new int[0]; } - // Get executable lines from compiled Page class - java.lang.reflect.Method getExecLinesMethod = page.getClass().getMethod("getExecutableLines"); - int[] lines = (int[]) getExecLinesMethod.invoke(page); - return lines; - } catch (NoSuchMethodException e) { + // Get executable lines from compiled Page class - returns Object[] {compileTime, lines} + java.lang.reflect.Method getExecLinesMethod = page.getClass().getMethod( "getExecutableLines" ); + Object result = getExecLinesMethod.invoke( page ); + + // Handle old Lucee versions that return int[] directly + if ( result instanceof int[] ) { + return (int[]) result; + } + + // New format: Object[] {compileTime (Long), lines (int[] or null)} + if ( result instanceof Object[] ) { + Object[] arr = (Object[]) result; + long compileTime = ( (Long) arr[0] ).longValue(); + int[] lines = (int[]) arr[1]; + + // Check cache + CachedExecutableLines cached = executableLinesCache.get( absolutePath ); + if ( cached != null && cached.compileTime == compileTime ) { + Log.debug( "getExecutableLines: cache hit for " + absolutePath ); + return cached.lines; + } + + // Cache miss or stale - update cache + int[] resultLines = lines != null ? lines : new int[0]; + executableLinesCache.put( absolutePath, new CachedExecutableLines( compileTime, resultLines ) ); + Log.debug( "getExecutableLines: cached " + resultLines.length + " lines for " + absolutePath ); + return resultLines; + } + + // Unexpected return type + Log.debug( "getExecutableLines: unexpected return type: " + ( result != null ? result.getClass().getName() : "null" ) ); + return new int[0]; + } + catch ( NoSuchMethodException e ) { // Method doesn't exist - Lucee version without this feature - Log.debug("getExecutableLines: method not found (old Lucee version?)"); + Log.debug( "getExecutableLines: method not found (old Lucee version?)" ); return new int[0]; - } catch (java.lang.reflect.InvocationTargetException e) { + } + catch ( java.lang.reflect.InvocationTargetException e ) { // Unwrap the real exception from reflection Throwable cause = e.getCause(); - Log.debug("getExecutableLines failed for " + absolutePath + ": " + - (cause != null ? cause.getClass().getName() + ": " + cause.getMessage() : e.getMessage())); + Log.debug( "getExecutableLines failed for " + absolutePath + ": " + + ( cause != null ? cause.getClass().getName() + ": " + cause.getMessage() : e.getMessage() ) ); return new int[0]; - } catch (Exception e) { - Log.debug("getExecutableLines failed for " + absolutePath + ": " + e.getClass().getName() + ": " + e.getMessage()); + } + catch ( Exception e ) { + Log.debug( "getExecutableLines failed for " + absolutePath + ": " + e.getClass().getName() + ": " + e.getMessage() ); return new int[0]; } } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java index 5d10a3e..3534e4b 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java @@ -257,7 +257,10 @@ private IDebugEntity[] getVariablesImpl(long variablesReference, IDebugEntity.De return new IDebugEntity[0]; } Object obj = maybeObj.get().obj; - return CfValueDebuggerBridge.getAsDebugEntity(valTracker, obj, which); + // Get the parent's path and frameId for setVariable support + String parentPath = valTracker.getPath(variablesReference); + Long frameId = valTracker.getFrameId(variablesReference); + return CfValueDebuggerBridge.getAsDebugEntity(valTracker, obj, which, parentPath, frameId); } // ========== Breakpoint operations ========== @@ -380,6 +383,91 @@ public Either> evaluate(int frame return GlobalIDebugManagerHolder.debugManager.evaluate((Long)(long)frameID, expr); } + @Override + public Either> setVariable(long variablesReference, String name, String value, long frameIdHint) { + // Get the frame to access PageContext + // First try using the frameId from ValTracker (associated with the variablesReference) + Long trackedFrameId = valTracker.getFrameId(variablesReference); + long actualFrameId = (trackedFrameId != null) ? trackedFrameId : frameIdHint; + + IDebugFrame frame = frameCache.get(actualFrameId); + if (frame == null) { + return Either.Left("Frame not found: " + actualFrameId); + } + + if (!(frame instanceof NativeDebugFrame)) { + return Either.Left("setVariable only supported for native frames"); + } + + NativeDebugFrame nativeFrame = (NativeDebugFrame) frame; + PageContext pc = nativeFrame.getPageContext(); + if (pc == null) { + return Either.Left("No PageContext available for frame"); + } + + // Get the parent path from ValTracker + String parentPath = valTracker.getPath(variablesReference); + if (parentPath == null) { + return Either.Left("Cannot determine variable path for variablesReference: " + variablesReference); + } + + // Build the full variable path + String fullPath = parentPath + "." + name; + Log.debug("setVariable: " + fullPath + " = " + value); + + try { + // Use reflection to access Lucee classes - in extension mode, direct class access fails + ClassLoader cl = luceeClassLoader != null ? luceeClassLoader : pc.getClass().getClassLoader(); + + // Get ThreadLocalPageContext class and methods via reflection + Class tlpcClass = cl.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method registerMethod = tlpcClass.getMethod("register", PageContext.class); + java.lang.reflect.Method releaseMethod = tlpcClass.getMethod("release"); + + // Get Evaluate class and call method via reflection + Class evaluateClass = cl.loadClass("lucee.runtime.functions.dynamicEvaluation.Evaluate"); + java.lang.reflect.Method callMethod = evaluateClass.getMethod("call", PageContext.class, Object[].class); + + // Register PageContext with ThreadLocal so Lucee functions work + registerMethod.invoke(null, pc); + + try { + // First, evaluate the value expression to get the actual object + Object evaluatedValue = callMethod.invoke(null, pc, new Object[]{value}); + + // Use Lucee's setVariable to set the value + Object result = pc.setVariable(fullPath, evaluatedValue); + + // Return the result as a debug entity + if (result == null) { + return Either.Right(Either.Right("null")); + } else if (result instanceof String) { + return Either.Right(Either.Right("\"" + ((String)result).replaceAll("\"", "\\\\\"") + "\"")); + } else if (result instanceof Number || result instanceof Boolean) { + return Either.Right(Either.Right(result.toString())); + } else { + // Complex object - wrap it for display + CfValueDebuggerBridge bridge = new CfValueDebuggerBridge(valTracker, result); + return Either.Right(Either.Left(bridge)); + } + } finally { + releaseMethod.invoke(null); + } + } catch (Throwable e) { + // Unwrap InvocationTargetException to get the real cause + Throwable cause = e; + if (e instanceof java.lang.reflect.InvocationTargetException && e.getCause() != null) { + cause = e.getCause(); + } + String msg = cause.getMessage(); + if (msg == null) { + msg = cause.getClass().getName(); + } + Log.debug("setVariable failed: " + msg); + return Either.Left("Error setting variable: " + msg); + } + } + @Override public void registerExceptionEventCallback(Consumer cb) { exceptionEventCallback = cb; diff --git a/luceedebug/src/main/java/luceedebug/coreinject/ValTracker.java b/luceedebug/src/main/java/luceedebug/coreinject/ValTracker.java index 994ab2f..6b43d1d 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/ValTracker.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/ValTracker.java @@ -21,6 +21,19 @@ public class ValTracker { private final Map wrapperByObj = Collections.synchronizedMap(new WeakHashMap<>()); private final Map wrapperByID = new ConcurrentHashMap<>(); + /** + * Track the variable path for each registered object ID. + * Used by setVariable to build the full path like "local.foo.bar". + * Path is the dot-separated path from the scope root (e.g., "local", "local.myStruct", "local.myStruct.nested"). + */ + private final Map pathById = new ConcurrentHashMap<>(); + + /** + * Track the frame ID for each registered object ID. + * Used by setVariable to get the correct PageContext. + */ + private final Map frameIdById = new ConcurrentHashMap<>(); + private static class WeakTaggedObject { // Start at 1, not 0 - DAP uses variablesReference=0 to mean "no children" private static final AtomicLong nextId = new AtomicLong(1); @@ -68,8 +81,10 @@ public void run() { // It would be nice to assert that wrapperByObj().size() == wrapperByID.size() after we're done here, but the entries for wrapperByObj // are cleaned non-deterministically (in the google guava case, the java sync'd WeakHashMap seems much more deterministic but maybe // not guaranteed to be so), so there's no guarantee that the sizes sync up. - + wrapperByID.remove(id); + pathById.remove(id); + frameIdById.remove(id); // __debug_updatedTracker("remove", id); } @@ -122,6 +137,78 @@ public Optional maybeGetFromId(long id) { return weakTaggedObj.maybeToStrong(); } + /** + * Register or update the variable path for an object ID. + * Called when registering scopes (path = scope name) or when expanding children (path = parent.childKey). + * @param id The variablesReference ID + * @param path The dot-separated path from scope root (e.g., "local", "local.foo", "local.foo.bar") + */ + public void setPath(long id, String path) { + if (path != null) { + pathById.put(id, path); + } + } + + /** + * Get the variable path for an object ID. + * @param id The variablesReference ID + * @return The path, or null if not tracked + */ + public String getPath(long id) { + return pathById.get(id); + } + + /** + * Register an object and set its path in one call. + * @param obj The object to register + * @param path The variable path for this object + * @return TaggedObject with the ID + */ + public TaggedObject registerObjectWithPath(Object obj, String path) { + TaggedObject tagged = idempotentRegisterObject(obj); + if (path != null) { + pathById.put(tagged.id, path); + } + return tagged; + } + + /** + * Register an object and set its path and frameId in one call. + * @param obj The object to register + * @param path The variable path for this object + * @param frameId The frame ID for this object (for setVariable support) + * @return TaggedObject with the ID + */ + public TaggedObject registerObjectWithPathAndFrameId(Object obj, String path, Long frameId) { + TaggedObject tagged = idempotentRegisterObject(obj); + if (path != null) { + pathById.put(tagged.id, path); + } + if (frameId != null) { + frameIdById.put(tagged.id, frameId); + } + return tagged; + } + + /** + * Set the frame ID for an object ID. + * Used by setVariable to get the correct PageContext. + * @param id The variablesReference ID + * @param frameId The frame ID + */ + public void setFrameId(long id, long frameId) { + frameIdById.put(id, frameId); + } + + /** + * Get the frame ID for an object ID. + * @param id The variablesReference ID + * @return The frame ID, or null if not tracked + */ + public Long getFrameId(long id) { + return frameIdById.get(id); + } + /** * debug/sanity check that tracked values are being cleaned up in both maps in response to gc events */ diff --git a/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java index 44ed925..79e1c3c 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java @@ -188,11 +188,24 @@ public void setLine( int line ) { } } + /** + * Get the PageContext for this frame. + * Used by setVariable to execute Lucee code in the correct context. + */ + public PageContext getPageContext() { + return pageContext; + } + private void checkedPutScopeRef( String name, Object scope ) { if ( scope != null && scope instanceof Map ) { var v = new MarkerTrait.Scope( (Map) scope ); CfValueDebuggerBridge.pin( v ); - scopes_.put( name, new CfValueDebuggerBridge( valTracker, v ) ); + var bridge = new CfValueDebuggerBridge( valTracker, v ); + // Track the path for setVariable support - scope name is the root path + valTracker.setPath( bridge.id, name ); + // Track the frame ID for setVariable support - needed to get PageContext + valTracker.setFrameId( bridge.id, id ); + scopes_.put( name, bridge ); } } @@ -288,7 +301,12 @@ private void addCfcatchScope() { var v = new MarkerTrait.Scope( cfcatch ); CfValueDebuggerBridge.pin( cfcatch ); CfValueDebuggerBridge.pin( v ); - scopes_.put( "cfcatch", new CfValueDebuggerBridge( valTracker, v ) ); + var bridge = new CfValueDebuggerBridge( valTracker, v ); + // Track the path for setVariable support + valTracker.setPath( bridge.id, "cfcatch" ); + // Track the frame ID for setVariable support + valTracker.setFrameId( bridge.id, id ); + scopes_.put( "cfcatch", bridge ); } /** From 8ef37051b8c4c1be87442550fd0371bc2c461b39 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sat, 6 Dec 2025 14:23:27 +0100 Subject: [PATCH 08/14] LDEV-1402 add evaluate support --- .../src/main/java/luceedebug/DapServer.java | 30 ++++++-- .../coreinject/CfValueDebuggerBridge.java | 7 +- .../luceedebug/coreinject/NativeLuceeVm.java | 71 +++++++++++++++++-- vscode-client/package.json | 5 ++ 4 files changed, 99 insertions(+), 14 deletions(-) diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index 4d991a8..33e12dc 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -33,6 +33,7 @@ public class DapServer implements IDebugProtocolServer { private final ILuceeVm luceeVm_; private final Config config_; private ArrayList pathTransforms = new ArrayList<>(); + private boolean evaluationEnabled = true; // for dev, system.out was fine, in some containers, others totally suppress it and it doesn't even // end up in log files. @@ -410,6 +411,11 @@ public CompletableFuture attach(Map args) { config_.setStepIntoUdfDefaultValueInitFrames(getBoolOrFalseIfNonBool(args.get("stepIntoUdfDefaultValueInitFrames"))); + evaluationEnabled = getAsBool(args.get("evaluation"), true); + if (!evaluationEnabled) { + Log.info("Expression evaluation disabled"); + } + clientProxy_.initialized(); if (pathTransforms.size() == 0) { @@ -1159,9 +1165,19 @@ CompletableFuture getSourcePath(GetSourcePathArguments ar static private AtomicLong anonymousID = new AtomicLong(); public CompletableFuture evaluate(EvaluateArguments args) { - Log.debug("evaluate() called: expression=" + args.getExpression() + ", context=" + args.getContext() + ", frameId=" + args.getFrameId()); + final String expr = args.getExpression(); + final String context = args.getContext(); // "hover", "watch", "repl", or null + final boolean isHover = "hover".equals(context); + + if (!evaluationEnabled) { + final var exceptionalResult = new CompletableFuture(); + final var error = new ResponseError(ResponseErrorCode.InvalidRequest, "evaluation disabled", null); + exceptionalResult.completeExceptionally(new ResponseErrorException(error)); + return exceptionalResult; + } + if (args.getFrameId() == null) { - Log.info("evaluate() - frameId is null, returning error"); + if (!isHover) { Log.info("evaluate(\"" + expr + "\") - error: missing frameID"); } final var exceptionalResult = new CompletableFuture(); final var error = new ResponseError(ResponseErrorCode.InvalidRequest, "missing frameID", null); exceptionalResult.completeExceptionally(new ResponseErrorException(error)); @@ -1169,10 +1185,10 @@ public CompletableFuture evaluate(EvaluateArguments args) { } else { return luceeVm_ - .evaluate(args.getFrameId(), args.getExpression()) + .evaluate(args.getFrameId(), expr) .collapse( errMsg -> { - Log.info("evaluate() - error: " + errMsg); + if (!isHover) { Log.info("evaluate(\"" + expr + "\") - error: " + errMsg); } final var exceptionalResult = new CompletableFuture(); final var error = new ResponseError(ResponseErrorCode.InternalError, errMsg, null); exceptionalResult.completeExceptionally(new ResponseErrorException(error)); @@ -1185,14 +1201,14 @@ public CompletableFuture evaluate(EvaluateArguments args) { final var response = new EvaluateResponse(); if (value == null) { // some problem, or we tried to get a function from a cfc maybe? this needs work. - Log.info("evaluate() - success (object) but value is null, returning ???"); + Log.info("evaluate(\"" + expr + "\") = ???"); response.setVariablesReference(0); response.setIndexedVariables(0); response.setNamedVariables(0); response.setResult("???"); } else { - Log.info("evaluate() - success (object): " + value.getValue()); + Log.info("evaluate(\"" + expr + "\") = " + value.getValue()); response.setVariablesReference((int)(long)value.getVariablesReference()); response.setIndexedVariables(value.getIndexedVariables()); response.setNamedVariables(value.getNamedVariables()); @@ -1202,7 +1218,7 @@ public CompletableFuture evaluate(EvaluateArguments args) { return CompletableFuture.completedFuture(response); }, string -> { - Log.info("evaluate() - success (string): " + string); + Log.info("evaluate(\"" + expr + "\") = " + string); final var response = new EvaluateResponse(); response.setResult(string); return CompletableFuture.completedFuture(response); diff --git a/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java b/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java index 743830a..3ea95c1 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java @@ -32,21 +32,24 @@ public static void pin(Object obj) { } private final Frame frame; + private final ValTracker valTracker; public final Object obj; public final long id; public CfValueDebuggerBridge(Frame frame, Object obj) { this.frame = Objects.requireNonNull(frame); + this.valTracker = frame.valTracker; this.obj = Objects.requireNonNull(obj); this.id = frame.valTracker.idempotentRegisterObject(obj).id; } /** * Constructor for use with native Lucee7 debugger frames where we don't have a Frame object. - * The frame field will be null - this is OK since it's not used after construction. + * The valTracker is stored directly since we don't have a Frame. */ public CfValueDebuggerBridge(ValTracker valTracker, Object obj) { this.frame = null; // Not available for native frames + this.valTracker = Objects.requireNonNull(valTracker); this.obj = Objects.requireNonNull(obj); this.id = valTracker.idempotentRegisterObject(obj).id; } @@ -203,7 +206,7 @@ private static IDebugEntity[] getAsCfArray(ValTracker valTracker, Array array, S } public IDebugEntity maybeNull_asValue(String name) { - return maybeNull_asValue(frame.valTracker, name, obj, true, false, null, null); + return maybeNull_asValue(valTracker, name, obj, true, false, null, null); } /** diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java index 3534e4b..ce7e1ed 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java @@ -375,12 +375,73 @@ public String getSourcePathForVariablesRef(int variablesRef) { @Override public Either> evaluate(int frameID, String expr) { - // In native mode, GlobalIDebugManagerHolder.debugManager is null - // TODO: Implement native evaluation using PageContext - if (GlobalIDebugManagerHolder.debugManager == null) { - return Either.Left("Expression evaluation not yet supported in native debugger mode"); + // For native mode, use the frame's PageContext to evaluate expressions + IDebugFrame frame = frameCache.get((long) frameID); + if (frame == null) { + return Either.Left("Frame not found: " + frameID); + } + + if (!(frame instanceof NativeDebugFrame)) { + // Fall back to JDWP mode if available + if (GlobalIDebugManagerHolder.debugManager != null) { + return GlobalIDebugManagerHolder.debugManager.evaluate((Long)(long)frameID, expr); + } + return Either.Left("evaluate only supported for native frames"); + } + + NativeDebugFrame nativeFrame = (NativeDebugFrame) frame; + PageContext pc = nativeFrame.getPageContext(); + if (pc == null) { + return Either.Left("No PageContext available for frame"); + } + + try { + // Use reflection to access Lucee classes - in extension mode, direct class access fails + ClassLoader cl = luceeClassLoader != null ? luceeClassLoader : pc.getClass().getClassLoader(); + + // Get ThreadLocalPageContext class and methods via reflection + Class tlpcClass = cl.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method registerMethod = tlpcClass.getMethod("register", PageContext.class); + java.lang.reflect.Method releaseMethod = tlpcClass.getMethod("release"); + + // Get Evaluate class and call method via reflection + Class evaluateClass = cl.loadClass("lucee.runtime.functions.dynamicEvaluation.Evaluate"); + java.lang.reflect.Method callMethod = evaluateClass.getMethod("call", PageContext.class, Object[].class); + + // Register PageContext with ThreadLocal so Lucee functions work + registerMethod.invoke(null, pc); + + try { + // Evaluate the expression + Object result = callMethod.invoke(null, pc, new Object[]{expr}); + + // Return the result as a debug entity + if (result == null) { + return Either.Right(Either.Right("null")); + } else if (result instanceof String) { + return Either.Right(Either.Right("\"" + ((String)result).replaceAll("\"", "\\\\\"") + "\"")); + } else if (result instanceof Number || result instanceof Boolean) { + return Either.Right(Either.Right(result.toString())); + } else { + // Complex object - wrap it for display + CfValueDebuggerBridge bridge = new CfValueDebuggerBridge(valTracker, result); + return Either.Right(Either.Left(bridge)); + } + } finally { + releaseMethod.invoke(null); + } + } catch (Throwable e) { + // Unwrap InvocationTargetException to get the real cause + Throwable cause = e; + if (e instanceof java.lang.reflect.InvocationTargetException && e.getCause() != null) { + cause = e.getCause(); + } + String msg = cause.getMessage(); + if (msg == null) { + msg = cause.getClass().getName(); + } + return Either.Left("Evaluation error: " + msg); } - return GlobalIDebugManagerHolder.debugManager.evaluate((Long)(long)frameID, expr); } @Override diff --git a/vscode-client/package.json b/vscode-client/package.json index a95ca22..46380bf 100644 --- a/vscode-client/package.json +++ b/vscode-client/package.json @@ -189,6 +189,11 @@ "secret": { "type": "string", "description": "Secret that must match LUCEE_DEBUGGER_SECRET environment variable on the server." + }, + "evaluation": { + "type": "boolean", + "default": true, + "description": "Enable expression evaluation in debug console, watch panel, and hover tooltips." } } } From 925d216b1a129c422fcfd09c7634a8018fd38f5d Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sat, 6 Dec 2025 14:55:12 +0100 Subject: [PATCH 09/14] LDEV-1402 add dump getMetaData() for variables and getApplicationSettings() --- .../src/main/java/luceedebug/DapServer.java | 14 + .../src/main/java/luceedebug/ILuceeVm.java | 2 + .../java/luceedebug/coreinject/LuceeVm.java | 10 + .../coreinject/NativeDebuggerListener.java | 16 + .../luceedebug/coreinject/NativeLuceeVm.java | 315 +++++++++++++++++- vscode-client/package.json | 13 + vscode-client/src/extension.ts | 57 +++- 7 files changed, 417 insertions(+), 10 deletions(-) diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index 33e12dc..ae96fef 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -968,6 +968,20 @@ CompletableFuture dumpAsJSON(DumpArguments args) { return CompletableFuture.completedFuture(response); } + @JsonRequest + CompletableFuture getMetadata(DumpArguments args) { + final var response = new DumpResponse(); + response.setContent(luceeVm_.getMetadata(args.variablesReference)); + return CompletableFuture.completedFuture(response); + } + + @JsonRequest + CompletableFuture getApplicationSettings(DumpArguments args) { + final var response = new DumpResponse(); + response.setContent(luceeVm_.getApplicationSettings()); + return CompletableFuture.completedFuture(response); + } + class DebugBreakpointBindingsResponse { /** as we see them on the server, after fs canonicalization */ private String[] canonicalFilenames; diff --git a/luceedebug/src/main/java/luceedebug/ILuceeVm.java b/luceedebug/src/main/java/luceedebug/ILuceeVm.java index 329d5c3..6788070 100644 --- a/luceedebug/src/main/java/luceedebug/ILuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/ILuceeVm.java @@ -78,6 +78,8 @@ public static BreakpointsChangedEvent justChanges(IBreakpoint[] changes) { public String dump(int dapVariablesReference); public String dumpAsJSON(int dapVariablesReference); + public String getMetadata(int dapVariablesReference); + public String getApplicationSettings(); public String[] getTrackedCanonicalFileNames(); /** diff --git a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java index d8ecdc0..17206de 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java @@ -1148,6 +1148,16 @@ public String dumpAsJSON(int dapVariablesReference) { return GlobalIDebugManagerHolder.debugManager.doDumpAsJSON(getSuspendedThreadListForDumpWorker(), dapVariablesReference); } + public String getMetadata(int dapVariablesReference) { + // Not implemented for JDWP mode - would need IDebugManager extension + return "\"getMetadata not supported in JDWP mode\""; + } + + public String getApplicationSettings() { + // Not implemented for JDWP mode - would need IDebugManager extension + return "\"getApplicationSettings not supported in JDWP mode\""; + } + public String[] getTrackedCanonicalFileNames() { final var result = new ArrayList(); for (var klassMap : klassMap_.values()) { diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java index 64a6ae0..3b9ac6a 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java @@ -409,6 +409,22 @@ public static int getBreakpointCount() { return bpLines.length; } + /** + * Get breakpoint details as array of [file, line] pairs. + * Used by debugBreakpointBindings command. + * @return Array of [serverPath, "line:N"] pairs + */ + public static String[][] getBreakpointDetails() { + int[] lines = bpLines; + String[] files = bpFiles; + String[][] result = new String[lines.length][2]; + for (int i = 0; i < lines.length; i++) { + result[i][0] = files[i]; + result[i][1] = "line:" + lines[i]; + } + return result; + } + /** * Check if a thread is natively suspended. */ diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java index ce7e1ed..110a5be 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java @@ -346,14 +346,245 @@ private int getStackDepthForThread(long threadID) { @Override public String dump(int dapVariablesReference) { - // Need a suspended thread to get PageContext for dump - // For native mode, we'd need to track suspended threads differently - return GlobalIDebugManagerHolder.debugManager.doDump(new ArrayList<>(), dapVariablesReference); + return doDumpNative(dapVariablesReference, false); } @Override public String dumpAsJSON(int dapVariablesReference) { - return GlobalIDebugManagerHolder.debugManager.doDumpAsJSON(new ArrayList<>(), dapVariablesReference); + return doDumpNative(dapVariablesReference, true); + } + + @Override + public String getMetadata(int dapVariablesReference) { + // Get the object from valTracker + var maybeObj = valTracker.maybeGetFromId(dapVariablesReference); + if (maybeObj.isEmpty()) { + return "\"Variable not found\""; + } + Object obj = maybeObj.get().obj; + + // Unwrap MarkerTrait.Scope if needed + if (obj instanceof CfValueDebuggerBridge.MarkerTrait.Scope) { + obj = ((CfValueDebuggerBridge.MarkerTrait.Scope) obj).scopelike; + } + + // Get PageContext from a cached frame + PageContext pc = null; + Long frameId = valTracker.getFrameId(dapVariablesReference); + if (frameId != null) { + IDebugFrame frame = frameCache.get(frameId); + if (frame instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) frame).getPageContext(); + } + } + + // Fallback: try any suspended frame's PageContext + if (pc == null) { + for (IDebugFrame frame : frameCache.values()) { + if (frame instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) frame).getPageContext(); + if (pc != null) break; + } + } + } + + if (pc == null) { + return "\"No PageContext available\""; + } + + return doGetMetadataWithPageContext(pc, obj); + } + + /** + * Execute getMetadata on a separate thread (required for PageContext registration). + */ + private String doGetMetadataWithPageContext(PageContext sourcePC, Object target) { + final var result = new Object() { + String value = "\"getMetadata failed\""; + }; + + final PageContext pc = sourcePC; + final Object obj = target; + + Thread thread = new Thread(() -> { + try { + ClassLoader cl = luceeClassLoader != null ? luceeClassLoader : pc.getClass().getClassLoader(); + + // Register the existing PageContext with ThreadLocal + Class tlpcClass = cl.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method registerMethod = tlpcClass.getMethod("register", PageContext.class); + java.lang.reflect.Method releaseMethod = tlpcClass.getMethod("release"); + registerMethod.invoke(null, pc); + + try { + // Call GetMetaData.call(PageContext, Object) + Class getMetaDataClass = cl.loadClass("lucee.runtime.functions.system.GetMetaData"); + java.lang.reflect.Method callMethod = getMetaDataClass.getMethod("call", + PageContext.class, Object.class); + Object metadata = callMethod.invoke(null, pc, obj); + + // Serialize the metadata to JSON + Class serializeClass = cl.loadClass("lucee.runtime.functions.conversion.SerializeJSON"); + java.lang.reflect.Method serializeMethod = serializeClass.getMethod("call", + PageContext.class, Object.class, Object.class); + result.value = (String) serializeMethod.invoke(null, pc, metadata, "struct"); + } finally { + releaseMethod.invoke(null); + } + } catch (Throwable e) { + Log.debug("getMetadata failed: " + e.getMessage()); + result.value = "\"Error: " + e.getMessage().replace("\"", "\\\"") + "\""; + } + }); + + thread.start(); + try { + thread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + return result.value; + } + + /** + * Native mode dump implementation using reflection to call Lucee functions. + * @param dapVariablesReference The variablesReference from DAP + * @param asJson If true, returns JSON; if false, returns HTML dump + */ + private String doDumpNative(int dapVariablesReference, boolean asJson) { + // Get the object from valTracker + var maybeObj = valTracker.maybeGetFromId(dapVariablesReference); + if (maybeObj.isEmpty()) { + return asJson ? "\"Variable not found\"" : "
Variable not found
"; + } + Object obj = maybeObj.get().obj; + + // Unwrap MarkerTrait.Scope if needed + if (obj instanceof CfValueDebuggerBridge.MarkerTrait.Scope) { + obj = ((CfValueDebuggerBridge.MarkerTrait.Scope) obj).scopelike; + } + + // Get the frameId for this variablesReference to get its PageContext + Long frameId = valTracker.getFrameId(dapVariablesReference); + PageContext pc = null; + if (frameId != null) { + IDebugFrame frame = frameCache.get(frameId); + if (frame instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) frame).getPageContext(); + } + } + + // If no PageContext from frame, try to find any suspended frame's PageContext + if (pc == null) { + for (IDebugFrame frame : frameCache.values()) { + if (frame instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) frame).getPageContext(); + if (pc != null) break; + } + } + } + + if (pc == null) { + return asJson ? "\"No PageContext available\"" : "
No PageContext available
"; + } + + return doDumpWithPageContext(pc, obj, asJson); + } + + /** + * Execute dump on a separate thread (required for PageContext registration). + */ + private String doDumpWithPageContext(PageContext sourcePC, Object someDumpable, boolean asJson) { + final var result = new Object() { + String value = asJson ? "\"dump failed\"" : "
dump failed
"; + }; + + final PageContext pc = sourcePC; + final Object dumpable = someDumpable; + + Thread thread = new Thread(() -> { + try { + ClassLoader cl = luceeClassLoader != null ? luceeClassLoader : pc.getClass().getClassLoader(); + + // Register the existing PageContext with ThreadLocal + Class tlpcClass = cl.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method registerMethod = tlpcClass.getMethod("register", PageContext.class); + java.lang.reflect.Method releaseMethod = tlpcClass.getMethod("release"); + registerMethod.invoke(null, pc); + + try { + if (asJson) { + // Call SerializeJSON + Class serializeClass = cl.loadClass("lucee.runtime.functions.conversion.SerializeJSON"); + java.lang.reflect.Method callMethod = serializeClass.getMethod("call", + PageContext.class, Object.class, Object.class); + result.value = (String) callMethod.invoke(null, pc, dumpable, "struct"); + } else { + // Use DumpUtil to get DumpData, then HTMLDumpWriter to render + result.value = wrapDumpInHtmlDoc(dumpObjectAsHtml(pc, cl, dumpable)); + } + } finally { + releaseMethod.invoke(null); + } + } catch (Throwable e) { + Log.debug("dump failed: " + e.getMessage()); + result.value = asJson + ? "\"Error: " + e.getMessage().replace("\"", "\\\"") + "\"" + : "
Error: " + e.getMessage() + "
"; + } + }); + + thread.start(); + try { + thread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + return result.value; + } + + /** + * Dump an object to HTML string using Lucee's HTMLDumpWriter. + */ + private String dumpObjectAsHtml(PageContext pc, ClassLoader cl, Object obj) throws Exception { + // Use DumpUtil to get DumpData, then HTMLDumpWriter to render + Class dumpUtilClass = cl.loadClass("lucee.runtime.dump.DumpUtil"); + Class dumpPropertiesClass = cl.loadClass("lucee.runtime.dump.DumpProperties"); + Class dumpDataClass = cl.loadClass("lucee.runtime.dump.DumpData"); + + // Get default dump properties - use DEFAULT_RICH field + java.lang.reflect.Field defaultField = dumpPropertiesClass.getField("DEFAULT_RICH"); + Object dumpProps = defaultField.get(null); + + // toDumpData(PageContext, Object, int maxlevel, DumpProperties) + java.lang.reflect.Method toDumpDataMethod = dumpUtilClass.getMethod("toDumpData", + PageContext.class, Object.class, int.class, dumpPropertiesClass); + Object dumpData = toDumpDataMethod.invoke(null, pc, obj, 9999, dumpProps); + + // Create HTMLDumpWriter and render + Class htmlDumpWriterClass = cl.loadClass("lucee.runtime.dump.HTMLDumpWriter"); + Object htmlWriter = htmlDumpWriterClass.getConstructor().newInstance(); + + // DumpWriter.toString(PageContext, DumpData) + java.lang.reflect.Method toStringMethod = htmlDumpWriterClass.getMethod("toString", + PageContext.class, dumpDataClass); + return (String) toStringMethod.invoke(htmlWriter, pc, dumpData); + } + + private static String wrapDumpInHtmlDoc(String dumpHtml) { + return "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + dumpHtml + + "\n" + + "\n"; } @Override @@ -364,13 +595,83 @@ public String[] getTrackedCanonicalFileNames() { @Override public String[][] getBreakpointDetail() { - // TODO: Return native breakpoint details - return new String[0][]; + return NativeDebuggerListener.getBreakpointDetails(); + } + + @Override + public String getApplicationSettings() { + // Get PageContext from any suspended frame + PageContext pc = null; + for (IDebugFrame frame : frameCache.values()) { + if (frame instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) frame).getPageContext(); + if (pc != null) break; + } + } + + if (pc == null) { + return "\"No PageContext available\""; + } + + return doGetApplicationSettingsWithPageContext(pc); + } + + /** + * Execute getApplicationSettings on a separate thread (required for PageContext registration). + */ + private String doGetApplicationSettingsWithPageContext(PageContext sourcePC) { + final var result = new Object() { + String value = "\"getApplicationSettings failed\""; + }; + + final PageContext pc = sourcePC; + + Thread thread = new Thread(() -> { + try { + ClassLoader cl = luceeClassLoader != null ? luceeClassLoader : pc.getClass().getClassLoader(); + + // Register the existing PageContext with ThreadLocal + Class tlpcClass = cl.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method registerMethod = tlpcClass.getMethod("register", PageContext.class); + java.lang.reflect.Method releaseMethod = tlpcClass.getMethod("release"); + registerMethod.invoke(null, pc); + + try { + // Call GetApplicationSettings.call(PageContext) + Class getAppSettingsClass = cl.loadClass("lucee.runtime.functions.system.GetApplicationSettings"); + java.lang.reflect.Method callMethod = getAppSettingsClass.getMethod("call", PageContext.class); + Object settings = callMethod.invoke(null, pc); + + // Serialize the settings to JSON + Class serializeClass = cl.loadClass("lucee.runtime.functions.conversion.SerializeJSON"); + java.lang.reflect.Method serializeMethod = serializeClass.getMethod("call", + PageContext.class, Object.class, Object.class); + result.value = (String) serializeMethod.invoke(null, pc, settings, "struct"); + } finally { + releaseMethod.invoke(null); + } + } catch (Throwable e) { + Log.debug("getApplicationSettings failed: " + e.getMessage()); + result.value = "\"Error: " + e.getMessage().replace("\"", "\\\"") + "\""; + } + }); + + thread.start(); + try { + thread.join(5000); // 5 second timeout + } catch (InterruptedException e) { + return "\"Timeout getting application settings\""; + } + + return result.value; } @Override public String getSourcePathForVariablesRef(int variablesRef) { - return GlobalIDebugManagerHolder.debugManager.getSourcePathForVariablesRef(variablesRef); + return valTracker + .maybeGetFromId(variablesRef) + .map(taggedObj -> CfValueDebuggerBridge.getSourcePath(taggedObj.obj)) + .orElse(null); } @Override diff --git a/vscode-client/package.json b/vscode-client/package.json index 46380bf..6af8780 100644 --- a/vscode-client/package.json +++ b/vscode-client/package.json @@ -55,6 +55,14 @@ { "when": "debugType == 'cfml'", "command": "luceedebug.openFileForVariableSourcePath" + }, + { + "when": "debugType == 'cfml'", + "command": "luceedebug.getMetadata" + }, + { + "when": "debugType == 'cfml'", + "command": "luceedebug.getApplicationSettings" } ] }, @@ -98,6 +106,11 @@ "command": "luceedebug.openFileForVariableSourcePath", "title": "luceedebug: open defining file", "enablement": "debugType == 'cfml'" + }, + { + "command": "luceedebug.getMetadata", + "title": "luceedebug: get metadata", + "enablement": "debugType == 'cfml'" } ], "debuggers": [ diff --git a/vscode-client/src/extension.ts b/vscode-client/src/extension.ts index b14020e..197d059 100644 --- a/vscode-client/src/extension.ts +++ b/vscode-client/src/extension.ts @@ -100,9 +100,9 @@ export function activate(context: vscode.ExtensionContext) { if (!currentDebugSession || args?.variable === undefined || args.variable.variablesReference === 0) { return; } - + const result : DumpResponse = await currentDebugSession.customRequest("dumpAsJSON", {variablesReference: args.variable.variablesReference}); - + let obj : any; try { obj = JSON.parse(result.content); @@ -115,7 +115,58 @@ export function activate(context: vscode.ExtensionContext) { const text = JSON.stringify(obj, undefined, 4); luceedebugTextDocumentProvider.addOrReplaceTextDoc(uri, text); - + + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + }), + vscode.commands.registerCommand("luceedebug.getMetadata", async (args?: Partial) => { + if (!currentDebugSession || args?.variable === undefined || args.variable.variablesReference === 0) { + return; + } + + const result : DumpResponse = await currentDebugSession.customRequest("getMetadata", {variablesReference: args.variable.variablesReference}); + + let obj : any; + try { + obj = JSON.parse(result.content); + } + catch { + obj = "Failed to parse the following JSON:\n" + result.content; + } + + const uri = vscode.Uri.from({scheme: "luceedebug", path: args.variable.name + ".metadata", fragment: args.variable.variablesReference.toString()}); + const text = JSON.stringify(obj, undefined, 4); + + luceedebugTextDocumentProvider.addOrReplaceTextDoc(uri, text); + + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + }), + vscode.commands.registerCommand("luceedebug.getApplicationSettings", async (args?: Partial) => { + if (!currentDebugSession) { + return; + } + // Only allow for the top-level application scope + if (!args?.container || args.container.name !== "application") { + vscode.window.showWarningMessage("getApplicationSettings is only available for the top-level application scope"); + return; + } + + const result : DumpResponse = await currentDebugSession.customRequest("getApplicationSettings", {variablesReference: args.container.variablesReference}); + + let obj : any; + try { + obj = JSON.parse(result.content); + } + catch { + obj = "Failed to parse the following JSON:\n" + result.content; + } + + const uri = vscode.Uri.from({scheme: "luceedebug", path: "applicationSettings", fragment: Date.now().toString()}); + const text = JSON.stringify(obj, undefined, 4); + + luceedebugTextDocumentProvider.addOrReplaceTextDoc(uri, text); + const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); }) From b4762138031d5cf32c746c0e01ffdb512113caf9 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sat, 6 Dec 2025 15:18:37 +0100 Subject: [PATCH 10/14] LDEV-1402 add supportsCompletions support for the debug console --- .../src/main/java/luceedebug/DapServer.java | 42 ++++++ .../src/main/java/luceedebug/ILuceeVm.java | 8 ++ .../java/luceedebug/coreinject/LuceeVm.java | 5 + .../luceedebug/coreinject/NativeLuceeVm.java | 123 ++++++++++++++++++ 4 files changed, 178 insertions(+) diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index ae96fef..a881cb1 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -329,6 +329,7 @@ public CompletableFuture initialize(InitializeRequestArguments arg c.setSupportsExceptionInfoRequest(true); c.setSupportsBreakpointLocationsRequest(true); c.setSupportsSetVariable(true); + c.setSupportsCompletionsRequest(true); Log.debug("Returning capabilities with exceptionBreakpointFilters: " + java.util.Arrays.toString(c.getExceptionBreakpointFilters())); return CompletableFuture.completedFuture(c); @@ -1242,6 +1243,47 @@ public CompletableFuture evaluate(EvaluateArguments args) { } } + @Override + public CompletableFuture completions(CompletionsArguments args) { + final String text = args.getText(); + final int column = args.getColumn(); + final Integer frameId = args.getFrameId(); + + // Parse the text to find what we're completing + // column is 1-based by default, text up to cursor + int cursorPos = Math.min(column - 1, text.length()); + String prefix = text.substring(0, cursorPos); + + // Find the start of the current "word" we're completing + // CFML variables can contain: letters, digits, underscores, dots, brackets + int wordStart = cursorPos; + while (wordStart > 0) { + char c = prefix.charAt(wordStart - 1); + if (Character.isLetterOrDigit(c) || c == '_' || c == '.' || c == '[' || c == ']' || c == '"' || c == '\'') { + wordStart--; + } else { + break; + } + } + + String partialExpr = prefix.substring(wordStart); + + // Get completions from the VM + CompletionItem[] items = luceeVm_.getCompletions(frameId != null ? frameId : 0, partialExpr); + + // Set start and length on each item so VSCode replaces the partial expression + // start is 0-based position in the text where replacement begins + // length is how many characters to replace (the partial expression length) + for (CompletionItem item : items) { + item.setStart(wordStart); // 0-based position where partial expr starts + item.setLength(partialExpr.length()); // Replace the typed prefix + } + + CompletionsResponse response = new CompletionsResponse(); + response.setTargets(items); + return CompletableFuture.completedFuture(response); + } + /** * Get the Lucee version string (e.g., "7.0.1.7-ALPHA"). */ diff --git a/luceedebug/src/main/java/luceedebug/ILuceeVm.java b/luceedebug/src/main/java/luceedebug/ILuceeVm.java index 6788070..19d0b65 100644 --- a/luceedebug/src/main/java/luceedebug/ILuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/ILuceeVm.java @@ -81,6 +81,14 @@ public static BreakpointsChangedEvent justChanges(IBreakpoint[] changes) { public String getMetadata(int dapVariablesReference); public String getApplicationSettings(); + /** + * Get completion suggestions for the debug console. + * @param frameId The stack frame ID for context (0 for global scope) + * @param partialExpr The partial expression to complete (e.g., "local.fo" or "variables.") + * @return Array of CompletionItems + */ + public org.eclipse.lsp4j.debug.CompletionItem[] getCompletions(int frameId, String partialExpr); + public String[] getTrackedCanonicalFileNames(); /** * array of tuples diff --git a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java index 17206de..2db9ea4 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java @@ -1158,6 +1158,11 @@ public String getApplicationSettings() { return "\"getApplicationSettings not supported in JDWP mode\""; } + public org.eclipse.lsp4j.debug.CompletionItem[] getCompletions(int frameId, String partialExpr) { + // Not implemented for JDWP mode + return new org.eclipse.lsp4j.debug.CompletionItem[0]; + } + public String[] getTrackedCanonicalFileNames() { final var result = new ArrayList(); for (var klassMap : klassMap_.values()) { diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java index 110a5be..0dc035c 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java @@ -674,6 +674,129 @@ public String getSourcePathForVariablesRef(int variablesRef) { .orElse(null); } + @Override + public org.eclipse.lsp4j.debug.CompletionItem[] getCompletions(int frameId, String partialExpr) { + // Get PageContext from frame or any suspended frame + PageContext pc = null; + IDebugFrame frame = frameCache.get((long) frameId); + if (frame instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) frame).getPageContext(); + } + if (pc == null) { + for (IDebugFrame f : frameCache.values()) { + if (f instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) f).getPageContext(); + if (pc != null) break; + } + } + } + + if (pc == null) { + return new org.eclipse.lsp4j.debug.CompletionItem[0]; + } + + return doGetCompletionsWithPageContext(pc, partialExpr); + } + + private org.eclipse.lsp4j.debug.CompletionItem[] doGetCompletionsWithPageContext(PageContext pc, String partialExpr) { + final java.util.List results = new java.util.ArrayList<>(); + + try { + ClassLoader cl = luceeClassLoader != null ? luceeClassLoader : pc.getClass().getClassLoader(); + + // Parse the expression: "local.foo.ba" -> base="local.foo", prefix="ba" + // Or just "va" -> base=null, prefix="va" + String base = null; + String prefix = partialExpr.toLowerCase(); + int lastDot = partialExpr.lastIndexOf('.'); + + if (lastDot > 0) { + base = partialExpr.substring(0, lastDot); + prefix = partialExpr.substring(lastDot + 1).toLowerCase(); + } + + if (base != null) { + // Evaluate the base to get keys + try { + Class tlpcClass = cl.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method registerMethod = tlpcClass.getMethod("register", PageContext.class); + java.lang.reflect.Method releaseMethod = tlpcClass.getMethod("release"); + Class evaluateClass = cl.loadClass("lucee.runtime.functions.dynamicEvaluation.Evaluate"); + java.lang.reflect.Method callMethod = evaluateClass.getMethod("call", PageContext.class, Object[].class); + + registerMethod.invoke(null, pc); + try { + Object result = callMethod.invoke(null, pc, new Object[]{base}); + if (result instanceof java.util.Map) { + @SuppressWarnings("unchecked") + java.util.Map map = (java.util.Map) result; + for (Object key : map.keySet()) { + String keyStr = String.valueOf(key); + if (keyStr.toLowerCase().startsWith(prefix)) { + var item = new org.eclipse.lsp4j.debug.CompletionItem(); + item.setLabel(keyStr); + item.setType(org.eclipse.lsp4j.debug.CompletionItemType.PROPERTY); + results.add(item); + } + } + } + } finally { + releaseMethod.invoke(null); + } + } catch (Exception e) { + // Evaluation failed, return empty + Log.debug("Completion evaluation failed: " + e.getMessage()); + } + } else { + // No base - complete from scope names and top-level scope variables + String[] scopes = {"variables", "local", "arguments", "form", "url", "cgi", "cookie", "session", "application", "server", "request", "this"}; + for (String scope : scopes) { + if (scope.toLowerCase().startsWith(prefix)) { + var item = new org.eclipse.lsp4j.debug.CompletionItem(); + item.setLabel(scope); + item.setType(org.eclipse.lsp4j.debug.CompletionItemType.MODULE); + results.add(item); + } + } + + // Also try to complete from variables scope + try { + Object variablesScope = pc.variablesScope(); + if (variablesScope instanceof java.util.Map) { + @SuppressWarnings("unchecked") + java.util.Map map = (java.util.Map) variablesScope; + for (Object key : map.keySet()) { + String keyStr = String.valueOf(key); + if (keyStr.toLowerCase().startsWith(prefix)) { + var item = new org.eclipse.lsp4j.debug.CompletionItem(); + item.setLabel(keyStr); + item.setType(org.eclipse.lsp4j.debug.CompletionItemType.VARIABLE); + results.add(item); + } + } + } + } catch (Exception e) { + // Ignore scope access errors + } + } + } catch (Exception e) { + Log.debug("Completion failed: " + e.getMessage()); + } + + // Sort by label and limit + results.sort((a, b) -> a.getLabel().compareToIgnoreCase(b.getLabel())); + + Log.info("Completions for '" + partialExpr + "': returning " + results.size() + " items"); + for (var item : results) { + Log.debug(" - " + item.getLabel()); + } + + if (results.size() > 100) { + return results.subList(0, 100).toArray(new org.eclipse.lsp4j.debug.CompletionItem[0]); + } + return results.toArray(new org.eclipse.lsp4j.debug.CompletionItem[0]); + } + @Override public Either> evaluate(int frameID, String expr) { // For native mode, use the frame's PageContext to evaluate expressions From e4ca7bce2d9d52c81e0de1192a70836541630eae Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sat, 6 Dec 2025 16:13:34 +0100 Subject: [PATCH 11/14] LDEV-1402 add supportsFunctionBreakpoints support --- .../src/main/java/luceedebug/DapServer.java | 47 +++++ .../coreinject/NativeDebuggerListener.java | 189 +++++++++++++++++- .../extension/ExtensionActivator.java | 29 ++- 3 files changed, 247 insertions(+), 18 deletions(-) diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index a881cb1..97708e7 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -330,6 +330,7 @@ public CompletableFuture initialize(InitializeRequestArguments arg c.setSupportsBreakpointLocationsRequest(true); c.setSupportsSetVariable(true); c.setSupportsCompletionsRequest(true); + c.setSupportsFunctionBreakpoints(true); Log.debug("Returning capabilities with exceptionBreakpointFilters: " + java.util.Arrays.toString(c.getExceptionBreakpointFilters())); return CompletableFuture.completedFuture(c); @@ -794,6 +795,52 @@ public CompletableFuture setExceptionBreakpoint return CompletableFuture.completedFuture(new SetExceptionBreakpointsResponse()); } + /** + * Handle function breakpoints - break when a function with a given name is called. + * Supports: + * - Simple names: "onRequestStart" matches any function with that name + * - Qualified names: "User.save" matches save() in User.cfc only + * - Wildcards: "on*" matches onRequestStart, onError, etc. + * - Conditions: "onRequestStart" with condition "cgi.script_name contains '/api/'" + */ + @Override + public CompletableFuture setFunctionBreakpoints( + SetFunctionBreakpointsArguments args) { + FunctionBreakpoint[] bps = args.getBreakpoints(); + Log.debug("setFunctionBreakpoints: " + (bps != null ? bps.length : 0) + " breakpoints"); + + if (bps == null || bps.length == 0) { + NativeDebuggerListener.clearFunctionBreakpoints(); + return CompletableFuture.completedFuture(new SetFunctionBreakpointsResponse()); + } + + String[] names = new String[bps.length]; + String[] conditions = new String[bps.length]; + + for (int i = 0; i < bps.length; i++) { + names[i] = bps[i].getName(); + conditions[i] = bps[i].getCondition(); + Log.debug(" Function breakpoint: " + names[i] + + (conditions[i] != null ? " condition=" + conditions[i] : "")); + } + + NativeDebuggerListener.setFunctionBreakpoints(names, conditions); + + // Build response - mark all as verified (we can't validate until runtime) + Breakpoint[] result = new Breakpoint[bps.length]; + for (int i = 0; i < bps.length; i++) { + Breakpoint bp = new Breakpoint(); + bp.setId(i + 1); + bp.setVerified(true); + bp.setMessage("Function breakpoint: " + names[i]); + result[i] = bp; + } + + SetFunctionBreakpointsResponse response = new SetFunctionBreakpointsResponse(); + response.setBreakpoints(result); + return CompletableFuture.completedFuture(response); + } + /** * Returns exception details when stopped due to an exception. * VSCode calls this after receiving a stopped event with reason="exception". diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java index 3b9ac6a..2a1b0f9 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java @@ -58,6 +58,23 @@ public static String getName() { private static int bpMaxLine = Integer.MIN_VALUE; private static int bpMaxPathLen = 0; + /** + * Function breakpoint storage - parallel arrays for fast lookup. + * Writers synchronize on funcBpLock, readers just access the arrays. + */ + private static final Object funcBpLock = new Object(); + private static String[] funcBpNames = new String[0]; // lowercase function names + private static String[] funcBpComponents = new String[0]; // lowercase component names (null = any) + private static String[] funcBpConditions = new String[0]; // CFML conditions (null = unconditional) + private static boolean[] funcBpIsWildcard = new boolean[0]; // true if name ends with * + + /** + * Pre-computed bounds for fast rejection in onFunctionEntry(). + */ + private static int funcBpMinLen = Integer.MAX_VALUE; + private static int funcBpMaxLen = Integer.MIN_VALUE; + private static volatile boolean hasFuncBps = false; + /** * Map of Java thread ID -> WeakReference for natively suspended threads. * Used to call debuggerResume() when DAP continue is received. @@ -190,7 +207,7 @@ private static class CachedExecutableLines { */ private static void updateHasSuspendConditions() { hasSuspendConditions = dapClientConnected && - (bpLines.length > 0 || breakOnUncaughtExceptions || !steppingThreads.isEmpty() || !threadsToPause.isEmpty()); + (bpLines.length > 0 || hasFuncBps || breakOnUncaughtExceptions || !steppingThreads.isEmpty() || !threadsToPause.isEmpty()); } /** @@ -875,19 +892,23 @@ public static boolean shouldSuspend(PageContext pc, String file, int line) { /** * Evaluate a CFML condition expression and return its boolean result. * Returns false if evaluation fails (exception, timeout, etc.). + * Uses reflection to call Lucee's Evaluate function to avoid classloader issues. */ private static boolean evaluateCondition(PageContext pc, String condition) { try { - // Use Evaluate.call() directly on PageContext - same approach as DebugManager - Object result = lucee.runtime.functions.dynamicEvaluation.Evaluate.call( - pc, - new String[]{ condition } - ); - return lucee.runtime.op.Caster.toBoolean(result); + // Use reflection to call Evaluate.call() through Lucee's classloader + ClassLoader luceeLoader = pc.getClass().getClassLoader(); + Class evaluateClass = luceeLoader.loadClass("lucee.runtime.functions.dynamicEvaluation.Evaluate"); + java.lang.reflect.Method callMethod = evaluateClass.getMethod("call", PageContext.class, Object[].class); + Object result = callMethod.invoke(null, pc, new Object[]{ condition }); + + // Cast result to boolean using Lucee's Caster + Class casterClass = luceeLoader.loadClass("lucee.runtime.op.Caster"); + java.lang.reflect.Method toBooleanMethod = casterClass.getMethod("toBoolean", Object.class); + return (Boolean) toBooleanMethod.invoke(null, result); } catch (Exception e) { // Condition evaluation failed - don't suspend - // Log but don't spam - conditions may intentionally reference undefined vars - Log.debug("Condition evaluation failed: " + e.getMessage()); + Log.error("Condition evaluation failed: " + e.getMessage()); return false; } } @@ -1080,4 +1101,154 @@ private static PageContext createTemporaryPageContext() { } return null; } + + // ========== Function Breakpoints ========== + + /** + * Check if we should suspend on function entry. + * Called from Lucee's pushDebuggerFrame via DebuggerListener.onFunctionEntry(). + * Must be blazing fast - every UDF call hits this. + */ + public static boolean onFunctionEntry( PageContext pc, String functionName, + String componentName, String file, int startLine ) { + // Fast path - no function breakpoints + if ( !hasFuncBps || !dapClientConnected ) { + return false; + } + + int len = functionName.length(); + + // Length bounds check - rejects 99% of calls instantly + if ( len < funcBpMinLen || len > funcBpMaxLen ) { + return false; + } + + // Normalize for case-insensitive matching + String lowerFunc = functionName.toLowerCase(); + String lowerComp = componentName != null ? componentName.toLowerCase() : null; + + // Check each breakpoint + String[] names = funcBpNames; + String[] comps = funcBpComponents; + String[] conds = funcBpConditions; + boolean[] wilds = funcBpIsWildcard; + + for ( int i = 0; i < names.length; i++ ) { + // Check component qualifier first (if specified) + if ( comps[i] != null && ( lowerComp == null || !lowerComp.equals( comps[i] ) ) ) { + continue; + } + + // Check function name match + boolean match; + if ( wilds[i] ) { + // Wildcard: "on*" matches "onRequestStart" + String prefix = names[i].substring( 0, names[i].length() - 1 ); + match = lowerFunc.startsWith( prefix ); + } + else { + match = lowerFunc.equals( names[i] ); + } + + if ( match ) { + // Check condition if present + if ( conds[i] != null ) { + if ( !evaluateCondition( pc, conds[i] ) ) { + continue; + } + } + Log.info( "Function breakpoint hit: " + functionName + + ( componentName != null ? " in " + componentName : "" ) ); + return true; + } + } + + return false; + } + + /** + * Set function breakpoints (replaces all existing). + * Called from DapServer.setFunctionBreakpoints(). + */ + public static void setFunctionBreakpoints( String[] names, String[] conditions ) { + synchronized ( funcBpLock ) { + int count = names.length; + String[] newNames = new String[count]; + String[] newComps = new String[count]; + String[] newConds = new String[count]; + boolean[] newWilds = new boolean[count]; + + int minLen = Integer.MAX_VALUE; + int maxLen = Integer.MIN_VALUE; + + for ( int i = 0; i < count; i++ ) { + String name = names[i].trim(); + String condition = ( conditions != null && i < conditions.length && conditions[i] != null && !conditions[i].isEmpty() ) + ? conditions[i] : null; + + // Parse qualified name: "Component.method" or just "method" + int dot = name.lastIndexOf( '.' ); + String compName = null; + String funcName = name; + if ( dot > 0 ) { + compName = name.substring( 0, dot ).toLowerCase(); + funcName = name.substring( dot + 1 ); + } + + // Check for wildcard + boolean isWild = funcName.endsWith( "*" ); + + // Store lowercase for case-insensitive matching + newNames[i] = funcName.toLowerCase(); + newComps[i] = compName; + newConds[i] = condition; + newWilds[i] = isWild; + + // Update bounds (for wildcards, use prefix length) + int effectiveLen = isWild ? funcName.length() - 1 : funcName.length(); + if ( !isWild ) { + // Exact match: bounds are exact length + if ( effectiveLen < minLen ) minLen = effectiveLen; + if ( effectiveLen > maxLen ) maxLen = effectiveLen; + } + else { + // Wildcard: any length >= prefix is possible + if ( effectiveLen < minLen ) minLen = effectiveLen; + maxLen = Integer.MAX_VALUE; // can't bound max for wildcards + } + + Log.info( "Function breakpoint: " + name + + ( compName != null ? " (component: " + compName + ")" : "" ) + + ( isWild ? " (wildcard)" : "" ) + + ( condition != null ? " condition: " + condition : "" ) ); + } + + funcBpNames = newNames; + funcBpComponents = newComps; + funcBpConditions = newConds; + funcBpIsWildcard = newWilds; + funcBpMinLen = count > 0 ? minLen : Integer.MAX_VALUE; + funcBpMaxLen = count > 0 ? maxLen : Integer.MIN_VALUE; + } + hasFuncBps = funcBpNames.length > 0; + updateHasSuspendConditions(); + Log.info( "Function breakpoints set: " + funcBpNames.length ); + } + + /** + * Clear all function breakpoints. + */ + public static void clearFunctionBreakpoints() { + synchronized ( funcBpLock ) { + funcBpNames = new String[0]; + funcBpComponents = new String[0]; + funcBpConditions = new String[0]; + funcBpIsWildcard = new boolean[0]; + funcBpMinLen = Integer.MAX_VALUE; + funcBpMaxLen = Integer.MIN_VALUE; + } + hasFuncBps = false; + updateHasSuspendConditions(); + Log.debug( "Function breakpoints cleared" ); + } } diff --git a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java index 6a1b5ee..6c6f526 100644 --- a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java +++ b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java @@ -220,21 +220,32 @@ private static boolean registerNativeDebuggerListener(ClassLoader luceeLoader, C pageContextClass, Throwable.class, boolean.class); final Method onOutputMethod = nativeListenerClass.getMethod("onOutput", String.class, boolean.class); + final Method onFunctionEntryMethod = nativeListenerClass.getMethod("onFunctionEntry", + pageContextClass, String.class, String.class, String.class, int.class); // Create proxy in Lucee's classloader, delegating to extension's implementation Object listenerProxy = java.lang.reflect.Proxy.newProxyInstance( luceeLoader, new Class[] { listenerInterface }, (proxy, method, args) -> { - switch (method.getName()) { - case "getName": return getNameMethod.invoke(null); - case "isClientConnected": return isDapClientConnectedMethod.invoke(null); - case "onSuspend": return onSuspendMethod.invoke(null, args); - case "onResume": return onResumeMethod.invoke(null, args); - case "shouldSuspend": return shouldSuspendMethod.invoke(null, args); - case "onException": return onExceptionMethod.invoke(null, args); - case "onOutput": return onOutputMethod.invoke(null, args); - default: return null; + try { + switch (method.getName()) { + case "getName": return getNameMethod.invoke(null); + case "isClientConnected": return isDapClientConnectedMethod.invoke(null); + case "onSuspend": return onSuspendMethod.invoke(null, args); + case "onResume": return onResumeMethod.invoke(null, args); + case "shouldSuspend": return shouldSuspendMethod.invoke(null, args); + case "onException": return onExceptionMethod.invoke(null, args); + case "onOutput": return onOutputMethod.invoke(null, args); + case "onFunctionEntry": return onFunctionEntryMethod.invoke(null, args); + default: return null; + } + } catch (java.lang.reflect.InvocationTargetException e) { + Throwable cause = e.getCause(); + Log.error("Proxy invocation failed for " + method.getName(), cause); + // Return safe defaults for boolean methods + if (method.getReturnType() == boolean.class) return false; + return null; } } ); From e6f24f8b9489595100f38c76889c30903cea0a69 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sun, 7 Dec 2025 16:53:23 +0100 Subject: [PATCH 12/14] LDEV-1402 rename logSystemOutput to consoleOutput --- .../src/main/java/luceedebug/DapServer.java | 4 ++-- luceedebug/src/main/java/luceedebug/Log.java | 22 +++++++++---------- .../coreinject/NativeDebuggerListener.java | 16 +++++++------- .../coreinject/frame/NativeDebugFrame.java | 4 ++-- vscode-client/package.json | 4 ++-- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index 97708e7..e8df81b 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -523,8 +523,8 @@ private void configureLogging(Map args) { // logExceptions - default false Log.setLogExceptions(caster.toBooleanValue(args.get("logExceptions"), false)); - // logSystemOutput - default false - NativeDebuggerListener.setLogSystemOutput(caster.toBooleanValue(args.get("logSystemOutput"), false)); + // consoleOutput - default false (streams System.out/err to debug console) + NativeDebuggerListener.setConsoleOutput(caster.toBooleanValue(args.get("consoleOutput"), false)); } static final Pattern threadNamePrefixAndDigitSuffix = Pattern.compile("^(.+?)(\\d+)$"); diff --git a/luceedebug/src/main/java/luceedebug/Log.java b/luceedebug/src/main/java/luceedebug/Log.java index e091378..d90e2da 100644 --- a/luceedebug/src/main/java/luceedebug/Log.java +++ b/luceedebug/src/main/java/luceedebug/Log.java @@ -29,7 +29,7 @@ public class Log { private static volatile boolean colorLogs = true; private static volatile LogLevel logLevel = LogLevel.INFO; private static volatile boolean logExceptions = false; - private static volatile boolean logSystemOutput = false; + private static volatile boolean consoleOutput = false; // Internal debugging - only enabled via env var LUCEE_DEBUGGER_DEBUG private static final boolean internalDebug; @@ -85,12 +85,12 @@ public static void setLogExceptions(boolean enabled) { } /** - * Set system output logging from launch.json. + * Set console output streaming from launch.json. * When enabled, we skip sending directly to DAP since System.out/err * will be captured and forwarded via systemOutput(). */ - public static void setLogSystemOutput(boolean enabled) { - logSystemOutput = enabled; + public static void setConsoleOutput(boolean enabled) { + consoleOutput = enabled; } /** @@ -101,9 +101,9 @@ public static void info(String message) { if (!LogLevel.INFO.isEnabled(logLevel)) { return; } - // When logSystemOutput is enabled, skip System.out (it gets captured and + // When consoleOutput is enabled, skip System.out (it gets captured and // forwarded to DAP, causing double-logging). Send directly to DAP instead. - if (!logSystemOutput) { + if (!consoleOutput) { String consoleMsg; if (colorLogs) { consoleMsg = ANSI_CYAN + PREFIX + ANSI_RESET + message; @@ -119,7 +119,7 @@ public static void info(String message) { * Log an error message. Always logged regardless of log level. */ public static void error(String message) { - if (!logSystemOutput) { + if (!consoleOutput) { String consoleMsg; if (colorLogs) { consoleMsg = ANSI_RED + PREFIX + "ERROR: " + message + ANSI_RESET; @@ -148,7 +148,7 @@ public static void debug(String message) { if (!internalDebug) { return; } - if (!logSystemOutput) { + if (!consoleOutput) { String consoleMsg; if (colorLogs) { consoleMsg = ANSI_DIM + PREFIX + "DEBUG: " + message + ANSI_RESET; @@ -169,7 +169,7 @@ public static void trace(String message) { if (!internalDebug) { return; } - if (!logSystemOutput) { + if (!consoleOutput) { String consoleMsg; if (colorLogs) { consoleMsg = ANSI_DIM + PREFIX + "TRACE: " + message + ANSI_RESET; @@ -190,7 +190,7 @@ public static void warn(String message) { if (!LogLevel.INFO.isEnabled(logLevel)) { return; } - if (!logSystemOutput) { + if (!consoleOutput) { String consoleMsg; if (colorLogs) { consoleMsg = ANSI_YELLOW + PREFIX + "WARN: " + message + ANSI_RESET; @@ -229,7 +229,7 @@ public static void exception(Throwable t) { /** * Forward System.out/err output to DAP client. - * Called by NativeDebuggerListener.onOutput() when logSystemOutput is enabled. + * Called by NativeDebuggerListener.onOutput() when consoleOutput is enabled. * Does NOT echo to console (would cause infinite loop). * * @param text The text that was written diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java index 2a1b0f9..72faa6f 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java @@ -167,9 +167,9 @@ private static class CachedExecutableLines { /** * Flag to forward System.out/err to DAP client. - * Set via launch.json logSystemOutput option. + * Set via launch.json consoleOutput option. */ - private static volatile boolean logSystemOutput = false; + private static volatile boolean consoleOutput = false; /** * Fast-path flag: true when there's anything that could cause a suspend. @@ -733,7 +733,7 @@ private static void onClientDisconnect() { // Reset exception settings breakOnUncaughtExceptions = false; - logSystemOutput = false; + consoleOutput = false; // Note: We intentionally keep breakpoints - they'll be inactive // since dapClientConnected=false, and will be replaced on next connect @@ -762,20 +762,20 @@ public static boolean shouldBreakOnUncaughtExceptions() { * Set whether to forward System.out/err to DAP client. * Called from DapServer when handling attach request. */ - public static void setLogSystemOutput(boolean enabled) { - logSystemOutput = enabled; - Log.setLogSystemOutput(enabled); + public static void setConsoleOutput(boolean enabled) { + consoleOutput = enabled; + Log.setConsoleOutput(enabled); } /** * Called by Lucee's DebuggerPrintStream when output is written to System.out/err. - * Forwards to DAP client if logSystemOutput is enabled. + * Forwards to DAP client if consoleOutput is enabled. * * @param text The text that was written * @param isStdErr true if stderr, false if stdout */ public static void onOutput(String text, boolean isStdErr) { - if (!logSystemOutput || !dapClientConnected) { + if (!consoleOutput || !dapClientConnected) { return; } Log.systemOutput(text, isStdErr); diff --git a/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java index 79e1c3c..cb4e94c 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java @@ -355,9 +355,9 @@ private static synchronized boolean initReflection( ClassLoader luceeClassLoader } try { - // Check if DEBUGGER_ENABLED is true (via env var) + // Check if debugger is enabled (via LUCEE_DEBUGGER_SECRET env var) if ( !EnvUtil.isDebuggerEnabled() ) { - Log.info( "Native frame support disabled: LUCEE_DEBUGGER_ENABLED not set" ); + Log.info( "Native frame support disabled: LUCEE_DEBUGGER_SECRET not set" ); nativeFrameSupportAvailable = false; return false; } diff --git a/vscode-client/package.json b/vscode-client/package.json index 6af8780..d804eed 100644 --- a/vscode-client/package.json +++ b/vscode-client/package.json @@ -194,10 +194,10 @@ "default": false, "description": "Log exceptions to the debug console." }, - "logSystemOutput": { + "consoleOutput": { "type": "boolean", "default": false, - "description": "Show System.out/err in the debug console." + "description": "Stream console output to the debug console (Lucee 7.1+)." }, "secret": { "type": "string", From 9d04ebcbc1ed0210b055c5cdc4e42dfc36de61bd Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sun, 7 Dec 2025 16:54:06 +0100 Subject: [PATCH 13/14] LDEV-1402 add cfml DAP client test suites --- .github/workflows/test-dap.yml | 499 ++++++++++++++++++ .gitignore | 6 + .vscode/settings.json | 4 - extension/META-INF/MANIFEST.MF | 4 +- luceedebug/build.gradle.kts | 2 +- .../src/main/java/luceedebug/Agent.java | 15 +- .../src/main/java/luceedebug/DapServer.java | 209 ++++++-- luceedebug/src/main/java/luceedebug/Log.java | 44 ++ .../luceedebug/coreinject/DebugManager.java | 58 +- .../java/luceedebug/coreinject/LuceeVm.java | 59 +-- .../coreinject/NativeDebuggerListener.java | 66 ++- .../luceedebug/coreinject/NativeLuceeVm.java | 9 +- .../extension/ExtensionActivator.java | 104 ++-- test/cfml/AuthTest.cfc | 87 +++ test/cfml/BreakpointLocationsTest.cfc | 126 +++++ test/cfml/BreakpointsTest.cfc | 147 ++++++ test/cfml/CompletionsTest.cfc | 202 +++++++ test/cfml/ConsoleOutputTest.cfc | 125 +++++ test/cfml/DapClient.cfc | 433 +++++++++++++++ test/cfml/DapTestCase.cfc | 222 ++++++++ test/cfml/DapTestCase.cfm | 239 +++++++++ test/cfml/EvaluateTest.cfc | 246 +++++++++ test/cfml/ExceptionBreakpointsTest.cfc | 188 +++++++ test/cfml/FunctionBreakpointsTest.cfc | 190 +++++++ test/cfml/README.md | 77 +++ test/cfml/SetVariableTest.cfc | 154 ++++++ test/cfml/SimpleTest.cfc | 10 + test/cfml/SteppingTest.cfc | 283 ++++++++++ test/cfml/VariablesTest.cfc | 236 +++++++++ test/cfml/artifacts/breakpoint-target.cfm | 25 + test/cfml/artifacts/completions-target.cfm | 30 ++ test/cfml/artifacts/console-output-target.cfm | 22 + test/cfml/artifacts/debug-threads.cfm | 27 + test/cfml/artifacts/evaluate-target.cfm | 37 ++ test/cfml/artifacts/exception-target.cfm | 36 ++ test/cfml/artifacts/function-bp-target.cfm | 40 ++ test/cfml/artifacts/set-variable-target.cfm | 28 + test/cfml/artifacts/stepping-target.cfm | 29 + test/cfml/artifacts/variables-target.cfm | 43 ++ test/cfml/test.bat | 8 + 40 files changed, 4181 insertions(+), 188 deletions(-) create mode 100644 .github/workflows/test-dap.yml delete mode 100644 .vscode/settings.json create mode 100644 test/cfml/AuthTest.cfc create mode 100644 test/cfml/BreakpointLocationsTest.cfc create mode 100644 test/cfml/BreakpointsTest.cfc create mode 100644 test/cfml/CompletionsTest.cfc create mode 100644 test/cfml/ConsoleOutputTest.cfc create mode 100644 test/cfml/DapClient.cfc create mode 100644 test/cfml/DapTestCase.cfc create mode 100644 test/cfml/DapTestCase.cfm create mode 100644 test/cfml/EvaluateTest.cfc create mode 100644 test/cfml/ExceptionBreakpointsTest.cfc create mode 100644 test/cfml/FunctionBreakpointsTest.cfc create mode 100644 test/cfml/README.md create mode 100644 test/cfml/SetVariableTest.cfc create mode 100644 test/cfml/SimpleTest.cfc create mode 100644 test/cfml/SteppingTest.cfc create mode 100644 test/cfml/VariablesTest.cfc create mode 100644 test/cfml/artifacts/breakpoint-target.cfm create mode 100644 test/cfml/artifacts/completions-target.cfm create mode 100644 test/cfml/artifacts/console-output-target.cfm create mode 100644 test/cfml/artifacts/debug-threads.cfm create mode 100644 test/cfml/artifacts/evaluate-target.cfm create mode 100644 test/cfml/artifacts/exception-target.cfm create mode 100644 test/cfml/artifacts/function-bp-target.cfm create mode 100644 test/cfml/artifacts/set-variable-target.cfm create mode 100644 test/cfml/artifacts/stepping-target.cfm create mode 100644 test/cfml/artifacts/variables-target.cfm create mode 100644 test/cfml/test.bat diff --git a/.github/workflows/test-dap.yml b/.github/workflows/test-dap.yml new file mode 100644 index 0000000..3c41fc7 --- /dev/null +++ b/.github/workflows/test-dap.yml @@ -0,0 +1,499 @@ +# DAP Tests for luceedebug +# +# Tests the Debug Adapter Protocol functionality against multiple Lucee versions. +# For 7.1 (native debugger branch), we build from source. +# +# Architecture: +# - Debuggee: Lucee Express (Tomcat) with luceedebug extension, DAP on port 10000, HTTP on 8888 +# - Test Runner: script-runner instance running TestBox tests, connects to debuggee + +name: DAP Tests + +on: [push, pull_request, workflow_dispatch] + +env: + EXPRESS_TEMPLATE_URL: https://cdn.lucee.org/express-templates/lucee-tomcat-11.0.13-template.zip + DAP_PORT: 10000 + DEBUGGEE_HTTP_PORT: 8888 + +jobs: + # Prime Maven cache by running script-runner once + # This ensures the cache is populated for subsequent jobs + prime-maven-cache: + name: Prime Maven cache + runs-on: ubuntu-latest + steps: + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: maven-cache + - name: write tmp cfm file + run: echo '#now()#' > prime-cache.cfm + - name: Prime cache with script-runner + uses: lucee/script-runner@main + with: + webroot: ${{ github.workspace }}/ + execute: /prime-cache.cfm + luceeVersionQuery: 7.0/all/light + + # Build Lucee 7.1 from the native debugger branch + build-lucee-71: + name: Build Lucee 7.1 (native debugger) + runs-on: ubuntu-latest + + steps: + - name: Checkout Lucee (native debugger branch) + uses: actions/checkout@v4 + with: + repository: zspitzer/Lucee + ref: LDEV-1402-native-debugger + path: lucee + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: lucee-maven-${{ hashFiles('lucee/**/pom.xml') }} + restore-keys: | + lucee-maven- + + - name: Build Lucee with ant fast + working-directory: lucee/loader + run: ant fast + + - name: Find built JAR + id: find-jar + working-directory: lucee/loader/target + run: | + JAR_FILE=$(ls lucee-*.jar | head -1) + echo "jar_name=$JAR_FILE" >> $GITHUB_OUTPUT + echo "Built JAR: $JAR_FILE" + + - name: Upload Lucee 7.1 JAR + uses: actions/upload-artifact@v4 + with: + name: lucee-71-jar + path: lucee/loader/target/lucee-*.jar + retention-days: 1 + + # Build luceedebug extension + build-extension: + name: Build luceedebug extension + runs-on: ubuntu-latest + + steps: + - name: Checkout luceedebug + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle- + + - name: Build extension with Gradle + run: ./gradlew buildExtension + + - name: Upload extension + uses: actions/upload-artifact@v4 + with: + name: luceedebug-extension + path: luceedebug/build/extension/*.lex + retention-days: 1 + + # Test against Lucee 7.1 (native debugger branch) + test-lucee-71: + name: Test DAP - Lucee 7.1 (native) + runs-on: ubuntu-latest + needs: [build-lucee-71, build-extension] + + steps: + - name: Checkout luceedebug + uses: actions/checkout@v4 + + - name: Checkout Lucee (for test framework) + uses: actions/checkout@v4 + with: + repository: lucee/lucee + path: lucee + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Download Lucee 7.1 JAR + uses: actions/download-artifact@v4 + with: + name: lucee-71-jar + path: lucee-jar + + - name: Download extension + uses: actions/download-artifact@v4 + with: + name: luceedebug-extension + path: extension + + - name: Find Lucee JAR + id: find-jar + run: | + JAR_FILE=$(ls lucee-jar/lucee-*.jar | head -1) + echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT + echo "jar_name=$(basename $JAR_FILE)" >> $GITHUB_OUTPUT + echo "Using Lucee JAR: $JAR_FILE" + + # Set up Lucee Express for debuggee + - name: Download Lucee Express template + run: | + curl -L -o express-template.zip "$EXPRESS_TEMPLATE_URL" + unzip -q express-template.zip -d debuggee + + - name: Install Lucee JAR into Express + run: | + # Remove any existing Lucee JAR + rm -f debuggee/lib/lucee-*.jar + # Copy our built JAR + cp ${{ steps.find-jar.outputs.jar_path }} debuggee/lib/ + + - name: Install extension into Express + run: | + mkdir -p debuggee/lucee-server/deploy + cp extension/*.lex debuggee/lucee-server/deploy/ + + - name: Copy test artifacts to debuggee webroot + run: | + mkdir -p debuggee/webapps/ROOT/test/cfml + cp -r test/cfml/artifacts debuggee/webapps/ROOT/test/cfml/ + + - name: Configure debuggee setenv.sh + run: | + echo 'export LUCEE_DEBUGGER_SECRET=testing' >> debuggee/bin/setenv.sh + echo 'export LUCEE_DEBUGGER_PORT=10000' >> debuggee/bin/setenv.sh + echo 'export LUCEE_LOGGING_FORCE_LEVEL=trace' >> debuggee/bin/setenv.sh + # Enable Felix OSGi debug logging to diagnose bundle unload + echo 'export FELIX_LOG_LEVEL=debug' >> debuggee/bin/setenv.sh + # Enable luceedebug internal debug logging + echo 'export LUCEE_DEBUGGER_DEBUG=true' >> debuggee/bin/setenv.sh + chmod +x debuggee/bin/setenv.sh + + - name: Warmup debuggee (Lucee Express) + run: | + cd debuggee + # Configure Tomcat to use port 8888 + sed -i 's/port="8080"/port="8888"/g' conf/server.xml + # Run warmup first - this compiles everything then exits + echo "Running Lucee warmup..." + export LUCEE_ENABLE_WARMUP=true + ./bin/catalina.sh run + echo "Warmup complete" + + - name: Start debuggee (Lucee Express) + run: | + cd debuggee + # Start as daemon - writes stdout to logs/catalina.out + echo "Starting debuggee..." + ./bin/catalina.sh start + echo "Debuggee started" + + - name: Wait for debuggee to be ready + run: | + echo "Waiting for HTTP on port 8888..." + for i in {1..30}; do + if curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/ | grep -q "200\|302\|404"; then + echo "HTTP ready after $i seconds" + break + fi + sleep 1 + done + # Verify artifact is accessible - fail fast if not + echo "Testing artifact access..." + STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/test/cfml/artifacts/breakpoint-target.cfm) + echo "Artifact HTTP status: $STATUS" + if [ "$STATUS" != "200" ]; then + echo "ERROR: Artifact not accessible!" + exit 1 + fi + echo "Waiting for DAP on port 10000..." + DAP_READY=false + for i in {1..10}; do + # Try both IPv4 and IPv6 (Java may bind to either depending on system config) + if nc -z 127.0.0.1 10000 2>/dev/null || nc -z ::1 10000 2>/dev/null; then + echo "DAP ready after $i seconds" + DAP_READY=true + break + fi + sleep 1 + done + if [ "$DAP_READY" != "true" ]; then + echo "ERROR: DAP port 10000 not listening!" + # Debug: show what's listening + echo "Listening ports:" + ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null || true + # Debug: dump luceedebug thread state + echo "Luceedebug thread state:" + curl -s http://localhost:8888/test/cfml/artifacts/debug-threads.cfm || echo "Failed to fetch thread dump" + exit 1 + fi + + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: maven-cache + + + - name: Cache Lucee downloads + uses: actions/cache@v4 + with: + path: _actions/lucee/script-runner/main/lucee-download-cache + key: lucee-downloads-71 + + # Run tests using script-runner (separate Lucee instance) + # Test runner uses 7.0 stable - it just needs to connect to the debuggee + # NOTE: Don't install luceedebug extension in test runner - only debuggee needs it + - name: Run DAP Tests + uses: lucee/script-runner@main + with: + webroot: ${{ github.workspace }}/lucee/test + execute: /bootstrap-tests.cfm + luceeVersionQuery: 7.0/all/light + env: + testLabels: dap + testAdditional: ${{ github.workspace }}/test/cfml + testDebug: "true" + DAP_HOST: localhost + DAP_PORT: "10000" + DAP_SECRET: testing + DEBUGGEE_HTTP: http://localhost:8888 + DEBUGGEE_ARTIFACT_PATH: ${{ github.workspace }}/debuggee/webapps/ROOT/test/cfml/artifacts/ + + - name: Stop debuggee + if: always() + run: | + cd debuggee + ./bin/shutdown.sh || true + + - name: Show catalina.out + if: always() + run: | + echo "=== catalina.out ===" + cat debuggee/logs/catalina.out || echo "No catalina.out found" + + - name: Upload debuggee logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: debuggee-logs-71 + path: | + debuggee/logs/ + debuggee/lucee-server/context/logs/ + debuggee/lucee-server/context/cfclasses/ + + # Test against Lucee 7.0 (stable) - agent mode + # Note: Does NOT use the extension (requires 7.1+), uses luceedebug as Java agent instead + test-lucee-70: + name: Test DAP - Lucee 7.0 (agent) + runs-on: ubuntu-latest + + steps: + - name: Checkout luceedebug + uses: actions/checkout@v4 + + - name: Checkout Lucee (for test framework) + uses: actions/checkout@v4 + with: + repository: lucee/lucee + path: lucee + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + # Set up Lucee Express for debuggee + # Note: Extension is NOT installed for 7.0 - it requires Lucee 7.1+ + # Agent mode uses luceedebug JAR as a Java agent instead + - name: Download Lucee Express template + run: | + curl -L -o express-template.zip "$EXPRESS_TEMPLATE_URL" + unzip -q express-template.zip -d debuggee + + - name: Download Lucee 7.0 JAR + run: | + # Get latest 7.0 snapshot filename via update API + LUCEE_FILENAME=$(curl -s "https://update.lucee.org/rest/update/provider/latest/7.0/all/jar/filename") + # Strip quotes if present (API returns quoted string) + LUCEE_FILENAME=$(echo "$LUCEE_FILENAME" | tr -d '"') + if [ -z "$LUCEE_FILENAME" ] || [[ "$LUCEE_FILENAME" == *"error"* ]]; then + LUCEE_FILENAME="lucee-7.0.2.1-SNAPSHOT.jar" + fi + LUCEE_URL="https://cdn.lucee.org/$LUCEE_FILENAME" + echo "Downloading Lucee from: $LUCEE_URL" + curl -L -f -o lucee.jar "$LUCEE_URL" + # Validate JAR is not corrupt + if ! unzip -t lucee.jar > /dev/null 2>&1; then + echo "ERROR: Downloaded JAR is corrupt!" + exit 1 + fi + # Remove existing and copy new + rm -f debuggee/lib/lucee-*.jar + cp lucee.jar debuggee/lib/ + + - name: Copy test artifacts to debuggee webroot + run: | + mkdir -p debuggee/webapps/ROOT/test/cfml + cp -r test/cfml/artifacts debuggee/webapps/ROOT/test/cfml/ + + - name: Build luceedebug agent JAR + run: | + ./gradlew shadowJar + AGENT_JAR=$(ls luceedebug/build/libs/luceedebug-*.jar | grep -v sources | head -1) + echo "AGENT_JAR=$AGENT_JAR" >> $GITHUB_ENV + cp $AGENT_JAR debuggee/ + + - name: Configure debuggee for agent mode + run: | + AGENT_JAR_NAME=$(basename $AGENT_JAR) + # Add JVM args for JDWP and luceedebug agent + # Secret is read from env var at connection time, not javaagent args + echo "export LUCEE_DEBUGGER_SECRET=testing" >> debuggee/bin/setenv.sh + echo "export LUCEE_LOGGING_FORCE_LEVEL=trace" >> debuggee/bin/setenv.sh + # Enable luceedebug internal debug logging + echo "export LUCEE_DEBUGGER_DEBUG=true" >> debuggee/bin/setenv.sh + echo "export CATALINA_OPTS=\"\$CATALINA_OPTS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=localhost:9999\"" >> debuggee/bin/setenv.sh + echo "export CATALINA_OPTS=\"\$CATALINA_OPTS -javaagent:\$CATALINA_HOME/$AGENT_JAR_NAME=jdwpHost=localhost,jdwpPort=9999,debugHost=0.0.0.0,debugPort=10000,jarPath=\$CATALINA_HOME/$AGENT_JAR_NAME\"" >> debuggee/bin/setenv.sh + chmod +x debuggee/bin/setenv.sh + + - name: Warmup debuggee (Lucee Express) + run: | + cd debuggee + # Configure Tomcat to use port 8888 + sed -i 's/port="8080"/port="8888"/g' conf/server.xml + # Run warmup first - this compiles everything then exits + echo "Running Lucee warmup..." + export LUCEE_ENABLE_WARMUP=true + ./bin/catalina.sh run + echo "Warmup complete" + + - name: Start debuggee (Lucee Express) + run: | + cd debuggee + # Start as daemon - writes stdout to logs/catalina.out + echo "Starting debuggee..." + ./bin/catalina.sh start + echo "Debuggee started" + + - name: Wait for debuggee to be ready + run: | + echo "Waiting for HTTP on port 8888..." + for i in {1..30}; do + if curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/ | grep -q "200\|302\|404"; then + echo "HTTP ready after $i seconds" + break + fi + sleep 1 + done + # Verify artifact is accessible - fail fast if not + echo "Testing artifact access..." + STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/test/cfml/artifacts/breakpoint-target.cfm) + echo "Artifact HTTP status: $STATUS" + if [ "$STATUS" != "200" ]; then + echo "ERROR: Artifact not accessible!" + exit 1 + fi + echo "Waiting for DAP on port 10000..." + DAP_READY=false + for i in {1..10}; do + # Try both IPv4 and IPv6 (Java may bind to either depending on system config) + if nc -z 127.0.0.1 10000 2>/dev/null || nc -z ::1 10000 2>/dev/null; then + echo "DAP ready after $i seconds" + DAP_READY=true + break + fi + sleep 1 + done + if [ "$DAP_READY" != "true" ]; then + echo "ERROR: DAP port 10000 not listening!" + # Debug: show what's listening + echo "Listening ports:" + ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null || true + # Debug: dump luceedebug thread state + echo "Luceedebug thread state:" + curl -s http://localhost:8888/test/cfml/artifacts/debug-threads.cfm || echo "Failed to fetch thread dump" + exit 1 + fi + + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: maven-cache + + - name: Cache Lucee downloads + uses: actions/cache@v4 + with: + path: _actions/lucee/script-runner/main/lucee-download-cache + key: lucee-downloads-70 + + # NOTE: Don't install luceedebug extension in test runner - only debuggee needs it + - name: Run DAP Tests + uses: lucee/script-runner@main + with: + webroot: ${{ github.workspace }}/lucee/test + execute: /bootstrap-tests.cfm + luceeVersionQuery: 7.0/all/light + env: + testLabels: dap + testAdditional: ${{ github.workspace }}/test/cfml + testDebug: "true" + DAP_HOST: localhost + DAP_PORT: "10000" + DAP_SECRET: testing + DEBUGGEE_HTTP: http://localhost:8888 + DEBUGGEE_ARTIFACT_PATH: ${{ github.workspace }}/debuggee/webapps/ROOT/test/cfml/artifacts/ + + - name: Stop debuggee + if: always() + run: | + cd debuggee + ./bin/shutdown.sh || true + + - name: Show catalina.out + if: always() + run: | + echo "=== catalina.out ===" + cat debuggee/logs/catalina.out || echo "No catalina.out found" + + - name: Upload debuggee logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: debuggee-logs-70 + path: | + debuggee/logs/ + debuggee/lucee-server/context/logs/ + debuggee/lucee-server/context/cfclasses/ diff --git a/.gitignore b/.gitignore index 0b8ccaf..9dd5919 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,9 @@ test/scratch /profiling/output /test-output /profiling/test-output +.vscode/settings.json +.claude/ +*.md +!README.md +build-output.txt +test-output.txt diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 0be1c0c..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "java.configuration.updateBuildConfiguration": "automatic", - "java.compile.nullAnalysis.mode": "automatic" -} \ No newline at end of file diff --git a/extension/META-INF/MANIFEST.MF b/extension/META-INF/MANIFEST.MF index 627b716..f5a98d4 100644 --- a/extension/META-INF/MANIFEST.MF +++ b/extension/META-INF/MANIFEST.MF @@ -4,7 +4,7 @@ name: "Luceedebug" symbolic-name: "luceedebug" description: "Native CFML debugger for VS Code - no Java agent required" version: "3.0.0" -lucee-core-version: "7.1.0.0" +lucee-core-version: "7.1.0.6" start-bundles: true release-type: server -startup-hook: [{"class": "luceedebug.extension.ExtensionActivator", "bundle-name": "luceedebug-osgi", "bundle-version": "2.0.1.1"}] +startup-hook: [{"class": "luceedebug.extension.ExtensionActivator", "bundle-name": "luceedebug-osgi", "bundle-version": "3.0.0.0"}] diff --git a/luceedebug/build.gradle.kts b/luceedebug/build.gradle.kts index 8283dd1..8527218 100644 --- a/luceedebug/build.gradle.kts +++ b/luceedebug/build.gradle.kts @@ -89,7 +89,7 @@ tasks.jar { "Premain-Class" to "luceedebug.Agent", "Can-Redefine-Classes" to "true", "Bundle-SymbolicName" to "luceedebug-osgi", - "Bundle-Version" to "2.0.1.1", + "Bundle-Version" to "3.0.0.0", "Export-Package" to "luceedebug.*" ) ) diff --git a/luceedebug/src/main/java/luceedebug/Agent.java b/luceedebug/src/main/java/luceedebug/Agent.java index 2a8ff0a..c7c4bc0 100644 --- a/luceedebug/src/main/java/luceedebug/Agent.java +++ b/luceedebug/src/main/java/luceedebug/Agent.java @@ -186,10 +186,23 @@ private static Map linearizedCoreInjectClasses() { result.put("luceedebug.coreinject.frame.Frame$FrameContext", 1); result.put("luceedebug.coreinject.frame.Frame$FrameContext$SupplierOrNull", 1); result.put("luceedebug.coreinject.frame.DummyFrame", 1); + result.put("luceedebug.coreinject.frame.NativeDebugFrame", 1); + + // Native debugger classes - not used in agent mode but need to be in the map + result.put("luceedebug.coreinject.NativeLuceeVm", 0); + result.put("luceedebug.coreinject.NativeLuceeVm$1", 0); + result.put("luceedebug.coreinject.NativeLuceeVm$2", 0); + result.put("luceedebug.coreinject.NativeLuceeVm$3", 0); + result.put("luceedebug.coreinject.NativeDebuggerListener", 0); + result.put("luceedebug.coreinject.NativeDebuggerListener$1", 0); + result.put("luceedebug.coreinject.NativeDebuggerListener$CachedExecutableLines", 0); + result.put("luceedebug.coreinject.NativeDebuggerListener$StepState", 0); + result.put("luceedebug.coreinject.NativeDebuggerListener$SuspendLocation", 0); + result.put("luceedebug.coreinject.StepMode", 0); return result; } - + public static Comparator comparator() { final Map ordering = linearizedCoreInjectClasses(); return Comparator.comparing(injection -> { diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index e8df81b..ffa35d9 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -25,6 +25,7 @@ import org.eclipse.lsp4j.jsonrpc.util.ToStringBuilder; import luceedebug.coreinject.NativeDebuggerListener; +import luceedebug.coreinject.NativeLuceeVm; import luceedebug.generated.Constants; import luceedebug.strong.CanonicalServerAbsPath; import luceedebug.strong.RawIdePath; @@ -175,15 +176,20 @@ private DapEntry(DapServer server, Launcher launcher) { } static public DapEntry createForSocket(ILuceeVm luceeVm, Config config, String host, int port) { + // Log immediately to confirm we entered the method + System.out.println("[luceedebug] createForSocket entered: host=" + host + ", port=" + port); + // Shut down any existing server first (handles extension reinstall within same classloader) shutdown(); ServerSocket server = null; try { + System.out.println("[luceedebug] Creating ServerSocket..."); server = new ServerSocket(); var addr = new InetSocketAddress(host, port); server.setReuseAddress(true); + System.out.println("[luceedebug] Binding to " + host + ":" + port); logger.finest("binding cf dap server socket on " + host + ":" + port); // Try to bind, with retries for port in use (OSGi bundle reload race condition) @@ -209,20 +215,27 @@ static public DapEntry createForSocket(ILuceeVm luceeVm, Config config, String h // Store in system properties so it survives classloader changes (OSGi bundle reload) System.getProperties().put(SOCKET_PROPERTY, server); + System.out.println("[luceedebug] DAP server socket bound successfully on " + host + ":" + port); logger.finest("dap server socket bind OK"); while (true) { + System.out.println("[luceedebug] Waiting for DAP client connection on " + host + ":" + port + "..."); + System.out.println("[luceedebug] ServerSocket state: bound=" + server.isBound() + ", closed=" + server.isClosed() + ", localPort=" + server.getLocalPort()); logger.finest("listening for inbound debugger connection on " + host + ":" + port + "..."); + System.out.println("[luceedebug] Calling server.accept()..."); var socket = server.accept(); + System.out.println("[luceedebug] accept() returned!"); var clientAddr = socket.getInetAddress().getHostAddress(); var clientPort = socket.getPort(); Log.info("DAP client connected from " + clientAddr + ":" + clientPort); logger.finest("accepted debugger connection from " + clientAddr + ":" + clientPort); - // Mark DAP client as connected - enables breakpoint() BIF to suspend - luceedebug.coreinject.NativeDebuggerListener.setDapClientConnected(true); + // Mark DAP client as connected - enables breakpoint() BIF to suspend (native mode only) + if (luceeVm instanceof NativeLuceeVm) { + luceedebug.coreinject.NativeDebuggerListener.setDapClientConnected(true); + } try { var rawIn = socket.getInputStream(); @@ -240,11 +253,14 @@ static public DapEntry createForSocket(ILuceeVm luceeVm, Config config, String h } catch (Exception e) { Log.error("DAP client error: " + e.getClass().getName(), e); } finally { - // Mark DAP client as disconnected - disables breakpoint() BIF suspension - luceedebug.coreinject.NativeDebuggerListener.setDapClientConnected(false); - Log.setDapClient(null); // Disable DAP output + // Clear DAP client FIRST to avoid broken pipe when setDapClientConnected logs + Log.setDapClient(null); + // Mark DAP client as disconnected - disables breakpoint() BIF suspension (native mode only) + if (luceeVm instanceof NativeLuceeVm) { + luceedebug.coreinject.NativeDebuggerListener.setDapClientConnected(false); + } try { socket.close(); } catch (Exception ignored) {} - Log.debug("DAP client socket closed for " + clientAddr + ":" + clientPort); + System.out.println("[luceedebug] Client socket closed, returning to accept loop"); } logger.finest("debugger connection closed"); @@ -252,15 +268,19 @@ static public DapEntry createForSocket(ILuceeVm luceeVm, Config config, String h } catch (java.net.SocketException e) { // Expected when shutdown() closes the socket + System.out.println("[luceedebug] DAP server SocketException: " + e.getMessage()); Log.info("DAP server socket closed"); return null; } catch (Throwable e) { + System.out.println("[luceedebug] DAP server fatal error: " + e.getClass().getName() + ": " + e.getMessage()); + e.printStackTrace(System.out); e.printStackTrace(); System.exit(1); return null; } finally { + System.out.println("[luceedebug] DAP server finally block executing"); // Only clear if we're still the active server (avoid race with new server starting) if (activeServerSocket == server) { activeServerSocket = null; @@ -275,25 +295,38 @@ static public DapEntry createForSocket(ILuceeVm luceeVm, Config config, String h * Uses reflection to avoid OSGi classloader identity issues. */ public static void shutdown() { + // Log who's calling shutdown with a stack trace + System.out.println("[luceedebug] DapServer.shutdown() called"); + System.out.println("[luceedebug] shutdown() caller stack trace:"); + for (StackTraceElement ste : java.lang.Thread.currentThread().getStackTrace()) { + System.out.println("[luceedebug] " + ste); + } + System.out.flush(); + // Try to get socket from JVM-wide properties (survives classloader changes) Object storedSocket = System.getProperties().get(SOCKET_PROPERTY); if (storedSocket != null) { // Use reflection - instanceof may fail across OSGi classloaders + System.out.println("[luceedebug] shutdown() - found socket in system properties, closing via reflection..."); Log.debug("shutdown() - found socket in system properties, closing via reflection..."); try { java.lang.reflect.Method closeMethod = storedSocket.getClass().getMethod("close"); closeMethod.invoke(storedSocket); + System.out.println("[luceedebug] shutdown() - socket closed"); Log.debug("shutdown() - socket closed"); } catch (Exception e) { + System.out.println("[luceedebug] shutdown() - socket close error: " + e); Log.error("shutdown() - socket close error", e); } System.getProperties().remove(SOCKET_PROPERTY); } else { + System.out.println("[luceedebug] shutdown() - no socket in system properties"); Log.debug("shutdown() - no socket in system properties"); } // Also close our local static reference if set (same classloader case) if (activeServerSocket != null) { + System.out.println("[luceedebug] shutdown() - closing activeServerSocket"); try { activeServerSocket.close(); } catch (Exception ignored) {} @@ -320,18 +353,24 @@ public CompletableFuture initialize(InitializeRequestArguments arg c.setSupportsHitConditionalBreakpoints(false); // still shows UI for it though c.setSupportsLogPoints(false); // still shows UI for it though - // Exception breakpoint filters - var uncaughtFilter = new ExceptionBreakpointsFilter(); - uncaughtFilter.setFilter("uncaught"); - uncaughtFilter.setLabel("Uncaught Exceptions"); - uncaughtFilter.setDescription("Break when an exception is not caught by a try/catch block"); - c.setExceptionBreakpointFilters(new ExceptionBreakpointsFilter[] { uncaughtFilter }); - c.setSupportsExceptionInfoRequest(true); - c.setSupportsBreakpointLocationsRequest(true); - c.setSupportsSetVariable(true); - c.setSupportsCompletionsRequest(true); - c.setSupportsFunctionBreakpoints(true); - Log.debug("Returning capabilities with exceptionBreakpointFilters: " + java.util.Arrays.toString(c.getExceptionBreakpointFilters())); + // Native-mode-only capabilities (require Lucee 7.1+ DebuggerRegistry) + boolean isNativeMode = luceeVm_ instanceof NativeLuceeVm; + + // Exception breakpoint filters - only supported in native mode + if (isNativeMode) { + var uncaughtFilter = new ExceptionBreakpointsFilter(); + uncaughtFilter.setFilter("uncaught"); + uncaughtFilter.setLabel("Uncaught Exceptions"); + uncaughtFilter.setDescription("Break when an exception is not caught by a try/catch block"); + c.setExceptionBreakpointFilters(new ExceptionBreakpointsFilter[] { uncaughtFilter }); + } + c.setSupportsExceptionInfoRequest(isNativeMode); + c.setSupportsBreakpointLocationsRequest(isNativeMode); + c.setSupportsSetVariable(isNativeMode); + c.setSupportsCompletionsRequest(isNativeMode); + c.setSupportsFunctionBreakpoints(isNativeMode); + + Log.debug("Returning capabilities (nativeMode=" + isNativeMode + ") with exceptionBreakpointFilters: " + java.util.Arrays.toString(c.getExceptionBreakpointFilters())); return CompletableFuture.completedFuture(c); } @@ -405,8 +444,10 @@ public CompletableFuture attach(Map args) { // Validate secret from launch.json if (!validateSecret(args)) { - clientProxy_.terminated(new org.eclipse.lsp4j.debug.TerminatedEventArguments()); - return CompletableFuture.completedFuture(null); + var result = new CompletableFuture(); + var error = new ResponseError(ResponseErrorCode.InvalidRequest, "Invalid or missing secret", null); + result.completeExceptionally(new ResponseErrorException(error)); + return result; } pathTransforms = tryMungePathTransforms(args.get("pathTransforms")); @@ -456,21 +497,30 @@ private boolean validateSecret(Map args) { } // Try native mode first (Lucee 7.1+ extension) + // The class exists in both modes (shadow JAR), but isNativeModeActive() returns true + // only if the extension was actually loaded by Lucee's startup-hook mechanism. try { Class activatorClass = Class.forName("luceedebug.extension.ExtensionActivator"); - java.lang.reflect.Method registerMethod = activatorClass.getMethod("registerListener", String.class); - Boolean registered = (Boolean) registerMethod.invoke(null, clientSecret); - if (registered) { - secretValidated = true; - return true; - } else { - Log.error("Failed to register debugger - invalid secret"); - return false; + java.lang.reflect.Method isNativeMethod = activatorClass.getMethod("isNativeModeActive"); + Boolean isNative = (Boolean) isNativeMethod.invoke(null); + + if (isNative) { + // We're in native mode - use ExtensionActivator to register + java.lang.reflect.Method registerMethod = activatorClass.getMethod("registerListener", String.class); + Boolean registered = (Boolean) registerMethod.invoke(null, clientSecret); + if (registered) { + secretValidated = true; + return true; + } else { + Log.error("Failed to register debugger - invalid secret"); + return false; + } } - } catch (ClassNotFoundException e) { // Not in native mode, fall through to agent mode validation + } catch (ClassNotFoundException e) { + // Class not found - shouldn't happen with shadow JAR but fall through anyway } catch (Exception e) { - Log.error("Error calling ExtensionActivator.registerListener", e); + Log.error("Error checking native mode status", e); return false; } @@ -492,15 +542,39 @@ private boolean validateSecret(Map args) { return true; } + /** + * Helper to reject requests when not authorized. + * Returns a failed CompletableFuture with appropriate error message. + */ + private CompletableFuture notAuthorized() { + var result = new CompletableFuture(); + var error = new ResponseError(ResponseErrorCode.InvalidRequest, "Not authorized - call 'attach' with valid secret first", null); + result.completeExceptionally(new ResponseErrorException(error)); + return result; + } + + /** + * Simple boolean conversion without depending on Lucee's Cast utility. + * Handles Boolean, String ("true"/"false"), and defaults. + */ + private static boolean toBooleanValue(Object obj, boolean defaultValue) { + if (obj == null) return defaultValue; + if (obj instanceof Boolean) return (Boolean) obj; + if (obj instanceof String) { + String s = ((String) obj).toLowerCase().trim(); + if ("true".equals(s) || "yes".equals(s) || "1".equals(s)) return true; + if ("false".equals(s) || "no".equals(s) || "0".equals(s)) return false; + } + return defaultValue; + } + /** * Configure logging from launch.json settings. * Supports: logColor (boolean), logLevel (error|info|debug), logExceptions (boolean), logSystemOutput (boolean) */ private void configureLogging(Map args) { - lucee.runtime.util.Cast caster = lucee.loader.engine.CFMLEngineFactory.getInstance().getCastUtil(); - // logColor - default true - Log.setColorLogs(caster.toBooleanValue(args.get("logColor"), true)); + Log.setColorLogs(toBooleanValue(args.get("logColor"), true)); // logLevel - error, info, debug Object logLevel = args.get("logLevel"); @@ -521,16 +595,21 @@ private void configureLogging(Map args) { } // logExceptions - default false - Log.setLogExceptions(caster.toBooleanValue(args.get("logExceptions"), false)); + Log.setLogExceptions(toBooleanValue(args.get("logExceptions"), false)); // consoleOutput - default false (streams System.out/err to debug console) - NativeDebuggerListener.setConsoleOutput(caster.toBooleanValue(args.get("consoleOutput"), false)); + // Only available in native mode + if (luceeVm_ instanceof NativeLuceeVm) { + NativeDebuggerListener.setConsoleOutput(toBooleanValue(args.get("consoleOutput"), false)); + } } static final Pattern threadNamePrefixAndDigitSuffix = Pattern.compile("^(.+?)(\\d+)$"); @Override public CompletableFuture threads() { + if (!secretValidated) return notAuthorized(); + var lspThreads = new ArrayList(); for (var threadInfo : luceeVm_.getThreadListing()) { @@ -573,6 +652,8 @@ public CompletableFuture threads() { @Override public CompletableFuture stackTrace(StackTraceArguments args) { + if (!secretValidated) return notAuthorized(); + var lspFrames = new ArrayList(); for (var cfFrame : luceeVm_.getStackTrace(args.getThreadId())) { @@ -600,6 +681,8 @@ public CompletableFuture stackTrace(StackTraceArguments args @Override public CompletableFuture scopes(ScopesArguments args) { + if (!secretValidated) return notAuthorized(); + var scopes = new ArrayList(); for (var entity : luceeVm_.getScopes(args.getFrameId())) { var scope = new Scope(); @@ -617,6 +700,8 @@ public CompletableFuture scopes(ScopesArguments args) { @Override public CompletableFuture variables(VariablesArguments args) { + if (!secretValidated) return notAuthorized(); + var variables = new ArrayList(); IDebugEntity[] entities = args.getFilter() == null ? luceeVm_.getVariables(args.getVariablesReference()) @@ -644,13 +729,7 @@ public CompletableFuture variables(VariablesArguments args) { public CompletableFuture setVariable(SetVariableArguments args) { Log.debug("setVariable() called: variablesReference=" + args.getVariablesReference() + ", name=" + args.getName() + ", value=" + args.getValue()); - // Don't allow setting variables if secret wasn't validated - if (!secretValidated) { - var exceptionalResult = new CompletableFuture(); - var error = new ResponseError(ResponseErrorCode.InvalidRequest, "Not authorized - secret not validated", null); - exceptionalResult.completeExceptionally(new ResponseErrorException(error)); - return exceptionalResult; - } + if (!secretValidated) return notAuthorized(); return luceeVm_ .setVariable(args.getVariablesReference(), args.getName(), args.getValue(), 0) @@ -695,12 +774,7 @@ public CompletableFuture setVariable(SetVariableArguments a @Override public CompletableFuture setBreakpoints(SetBreakpointsArguments args) { - // Don't accept breakpoints if secret wasn't validated - if (!secretValidated) { - var response = new SetBreakpointsResponse(); - response.setBreakpoints(new Breakpoint[0]); - return CompletableFuture.completedFuture(response); - } + if (!secretValidated) return notAuthorized(); final var idePath = new RawIdePath(args.getSource().getPath()); final var serverAbsPath = new CanonicalServerAbsPath(applyPathTransformsIdeToCf(args.getSource().getPath())); @@ -736,6 +810,8 @@ private Breakpoint map_cfBreakpoint_to_lsp4jBreakpoint(IBreakpoint cfBreakpoint) @Override public CompletableFuture breakpointLocations(BreakpointLocationsArguments args) { + if (!secretValidated) return notAuthorized(); + var response = new BreakpointLocationsResponse(); // Only works in native mode with NativeLuceeVm @@ -779,6 +855,8 @@ public CompletableFuture breakpointLocations(Breakp */ @Override public CompletableFuture setExceptionBreakpoints(SetExceptionBreakpointsArguments args) { + if (!secretValidated) return notAuthorized(); + // Check if "uncaught" is in the filters String[] filters = args.getFilters(); Log.debug("setExceptionBreakpoints: filters=" + java.util.Arrays.toString(filters)); @@ -791,7 +869,10 @@ public CompletableFuture setExceptionBreakpoint } } } - NativeDebuggerListener.setBreakOnUncaughtExceptions(breakOnUncaught); + // Only works in native mode - NativeDebuggerListener doesn't exist in agent mode + if (luceeVm_ instanceof NativeLuceeVm) { + NativeDebuggerListener.setBreakOnUncaughtExceptions(breakOnUncaught); + } return CompletableFuture.completedFuture(new SetExceptionBreakpointsResponse()); } @@ -806,6 +887,13 @@ public CompletableFuture setExceptionBreakpoint @Override public CompletableFuture setFunctionBreakpoints( SetFunctionBreakpointsArguments args) { + if (!secretValidated) return notAuthorized(); + + // Only works in native mode - NativeDebuggerListener doesn't exist in agent mode + if (!(luceeVm_ instanceof NativeLuceeVm)) { + return CompletableFuture.completedFuture(new SetFunctionBreakpointsResponse()); + } + FunctionBreakpoint[] bps = args.getBreakpoints(); Log.debug("setFunctionBreakpoints: " + (bps != null ? bps.length : 0) + " breakpoints"); @@ -847,6 +935,8 @@ public CompletableFuture setFunctionBreakpoints( */ @Override public CompletableFuture exceptionInfo(ExceptionInfoArguments args) { + if (!secretValidated) return notAuthorized(); + Log.debug("exceptionInfo() called for thread " + args.getThreadId()); Throwable ex = luceeVm_.getExceptionForThread(args.getThreadId()); var response = new ExceptionInfoResponse(); @@ -890,6 +980,8 @@ public CompletableFuture exceptionInfo(ExceptionInfoArgum */ @Override public CompletableFuture pause(PauseArguments args) { + if (!secretValidated) return notAuthorized(); + long threadId = args.getThreadId(); Log.info("pause() called for thread " + threadId); // Thread ID 0 means "pause all threads" - this happens when user clicks @@ -908,24 +1000,28 @@ public CompletableFuture disconnect(DisconnectArguments args) { @Override public CompletableFuture continue_(ContinueArguments args) { + if (!secretValidated) return notAuthorized(); luceeVm_.continue_(args.getThreadId()); return CompletableFuture.completedFuture(new ContinueResponse()); } @Override public CompletableFuture next(NextArguments args) { + if (!secretValidated) return notAuthorized(); luceeVm_.stepOver(args.getThreadId()); return CompletableFuture.completedFuture(null); } @Override public CompletableFuture stepIn(StepInArguments args) { + if (!secretValidated) return notAuthorized(); luceeVm_.stepIn(args.getThreadId()); return CompletableFuture.completedFuture(null); } @Override public CompletableFuture stepOut(StepOutArguments args) { + if (!secretValidated) return notAuthorized(); luceeVm_.stepOut(args.getThreadId()); return CompletableFuture.completedFuture(null); } @@ -1004,6 +1100,7 @@ public boolean equals(final Object obj) { @JsonRequest CompletableFuture dump(DumpArguments args) { + if (!secretValidated) return notAuthorized(); final var response = new DumpResponse(); response.setContent(luceeVm_.dump(args.variablesReference)); return CompletableFuture.completedFuture(response); @@ -1011,6 +1108,7 @@ CompletableFuture dump(DumpArguments args) { @JsonRequest CompletableFuture dumpAsJSON(DumpArguments args) { + if (!secretValidated) return notAuthorized(); final var response = new DumpResponse(); response.setContent(luceeVm_.dumpAsJSON(args.variablesReference)); return CompletableFuture.completedFuture(response); @@ -1018,6 +1116,7 @@ CompletableFuture dumpAsJSON(DumpArguments args) { @JsonRequest CompletableFuture getMetadata(DumpArguments args) { + if (!secretValidated) return notAuthorized(); final var response = new DumpResponse(); response.setContent(luceeVm_.getMetadata(args.variablesReference)); return CompletableFuture.completedFuture(response); @@ -1025,6 +1124,7 @@ CompletableFuture getMetadata(DumpArguments args) { @JsonRequest CompletableFuture getApplicationSettings(DumpArguments args) { + if (!secretValidated) return notAuthorized(); final var response = new DumpResponse(); response.setContent(luceeVm_.getApplicationSettings()); return CompletableFuture.completedFuture(response); @@ -1118,6 +1218,7 @@ class DebugBreakpointBindingsArguments { @JsonRequest CompletableFuture debugBreakpointBindings(DebugBreakpointBindingsArguments args) { + if (!secretValidated) return notAuthorized(); final var response = new DebugBreakpointBindingsResponse(); response.setCanonicalFilenames(luceeVm_.getTrackedCanonicalFileNames()); response.setBreakpoints(luceeVm_.getBreakpointDetail()); @@ -1211,6 +1312,7 @@ public boolean equals(final Object obj) { @JsonRequest CompletableFuture getSourcePath(GetSourcePathArguments args) { + if (!secretValidated) return notAuthorized(); final var response = new GetSourcePathResponse(); final var serverPath = luceeVm_.getSourcePathForVariablesRef(args.getVariablesReference()); @@ -1227,6 +1329,8 @@ CompletableFuture getSourcePath(GetSourcePathArguments ar static private AtomicLong anonymousID = new AtomicLong(); public CompletableFuture evaluate(EvaluateArguments args) { + if (!secretValidated) return notAuthorized(); + final String expr = args.getExpression(); final String context = args.getContext(); // "hover", "watch", "repl", or null final boolean isHover = "hover".equals(context); @@ -1292,6 +1396,8 @@ public CompletableFuture evaluate(EvaluateArguments args) { @Override public CompletableFuture completions(CompletionsArguments args) { + if (!secretValidated) return notAuthorized(); + final String text = args.getText(); final int column = args.getColumn(); final Integer frameId = args.getFrameId(); @@ -1333,12 +1439,15 @@ public CompletableFuture completions(CompletionsArguments a /** * Get the Lucee version string (e.g., "7.0.1.7-ALPHA"). + * In agent mode, Lucee classes may not be accessible, so we catch Throwable. */ private static String getLuceeVersion() { try { lucee.Info info = lucee.loader.engine.CFMLEngineFactory.getInstance().getInfo(); return info.getVersion().toString(); - } catch (Exception e) { + } catch (Throwable t) { + // NoClassDefFoundError is an Error, not Exception - need to catch Throwable + // This happens in agent mode where Lucee's classloader is separate return "unknown"; } } diff --git a/luceedebug/src/main/java/luceedebug/Log.java b/luceedebug/src/main/java/luceedebug/Log.java index d90e2da..7ea6994 100644 --- a/luceedebug/src/main/java/luceedebug/Log.java +++ b/luceedebug/src/main/java/luceedebug/Log.java @@ -4,6 +4,8 @@ import org.eclipse.lsp4j.debug.OutputEventArgumentsCategory; import org.eclipse.lsp4j.debug.services.IDebugProtocolClient; +import lucee.loader.engine.CFMLEngineFactory; + /** * Centralized logging for luceedebug. * Routes all log messages through a common method that: @@ -11,9 +13,11 @@ * - Optionally sends to DAP OutputEvent when a client is connected * - Supports ANSI colors (configurable via launch.json colorLogs, default true) * - Respects log level (configurable via launch.json logLevel, default info) + * - Writes errors to Lucee's exception.log when available */ public class Log { private static final String PREFIX = "[luceedebug] "; + private static final String APP_NAME = "luceedebug"; // ANSI escape codes (for console/tomcat output) private static final String ANSI_RESET = "\u001b[0m"; @@ -117,6 +121,7 @@ public static void info(String message) { /** * Log an error message. Always logged regardless of log level. + * Also logs to Lucee's exception.log when available. */ public static void error(String message) { if (!consoleOutput) { @@ -129,14 +134,17 @@ public static void error(String message) { System.out.println(consoleMsg); } sendToDap("ERROR: " + message, OutputEventArgumentsCategory.STDERR); + logToLuceeException(message); } /** * Log an error with exception. + * Also logs to Lucee's exception.log when available. */ public static void error(String message, Throwable t) { error(message + ": " + t.getMessage()); t.printStackTrace(); + logToLuceeException(message, t); } /** @@ -267,4 +275,40 @@ private static void sendToDap(String message, String category) { } } } + + /** + * Get Lucee's exception log if available. + * Returns null if Lucee is not running or log cannot be obtained. + */ + private static lucee.commons.io.log.Log getLuceeExceptionLog() { + try { + var config = CFMLEngineFactory.getInstance().getThreadConfig(); + if (config != null) { + return config.getLog("exception"); + } + } catch (Throwable t) { + // Lucee not available or not initialized yet - silently ignore + } + return null; + } + + /** + * Log an error to Lucee's exception.log if available. + */ + private static void logToLuceeException(String message) { + var luceeLog = getLuceeExceptionLog(); + if (luceeLog != null) { + luceeLog.error(APP_NAME, message); + } + } + + /** + * Log an error with throwable to Lucee's exception.log if available. + */ + private static void logToLuceeException(String message, Throwable t) { + var luceeLog = getLuceeExceptionLog(); + if (luceeLog != null) { + luceeLog.error(APP_NAME, message, t); + } + } } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java b/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java index 3e1b227..76548de 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java @@ -33,7 +33,6 @@ import luceedebug.coreinject.frame.DebugFrame; import luceedebug.coreinject.frame.Frame; import luceedebug.coreinject.frame.Frame.FrameContext; -import luceedebug.coreinject.frame.NativeDebugFrame; public class DebugManager implements IDebugManager { @@ -74,7 +73,12 @@ public void spawnWorker(Config config, String jdwpHost, int jdwpPort, String deb new Thread(() -> { System.out.println("[luceedebug] jdwp self connect OK"); - DapServer.createForSocket(luceeVm, config, debugHost, debugPort); + try { + DapServer.createForSocket(luceeVm, config, debugHost, debugPort); + } catch (Throwable t) { + System.out.println("[luceedebug] DAP server thread failed: " + t.getMessage()); + t.printStackTrace(); + } }, threadName).start(); } @@ -498,32 +502,18 @@ synchronized public IDebugEntity[] getVariables(long id, IDebugEntity.DebugEntit } synchronized public IDebugFrame[] getCfStack(Thread thread) { + System.out.println("[luceedebug] getCfStack: looking for thread=" + thread.getName() + " (id=" + thread.getId() + ") identity=" + System.identityHashCode(thread)); + System.out.println("[luceedebug] getCfStack: cfStackByThread has " + cfStackByThread.size() + " entries:"); + for (var entry : cfStackByThread.entrySet()) { + Thread t = entry.getKey(); + System.out.println("[luceedebug] thread=" + t.getName() + " (id=" + t.getId() + ") identity=" + System.identityHashCode(t) + " frames=" + entry.getValue().size()); + } ArrayList stack = cfStackByThread.get(thread); - // If no instrumented frames, try native Lucee7 frames + // Agent mode: only use bytecode-instrumented frames, no native fallback if (stack == null || stack.isEmpty()) { - // Try our tracked PageContext first - WeakReference pcRef = pageContextByThread.get(thread); - PageContext pc = pcRef != null ? pcRef.get() : null; - - // Fall back to ThreadLocalPageContext if we're on the same thread - if (pc == null && thread == Thread.currentThread()) { - pc = lucee.runtime.engine.ThreadLocalPageContext.get(); - } - - if (pc != null) { - // In agent mode, pass null for classloader - classes are injected into Lucee's classloader - IDebugFrame[] nativeFrames = NativeDebugFrame.getNativeFrames(pc, valTracker, -1, null); - if (nativeFrames != null && nativeFrames.length > 0) { - return nativeFrames; - } - } - - if (stack == null) { - System.out.println("getCfStack called, frames was null, frames is " + cfStackByThread + ", passed thread was " + thread); - System.out.println(" thread=" + thread + " this=" + this); - return new Frame[0]; - } + System.out.println("[luceedebug] getCfStack: no instrumented frames for thread " + thread); + return new Frame[0]; } ArrayList result = new ArrayList<>(); @@ -532,9 +522,11 @@ synchronized public IDebugFrame[] getCfStack(Thread thread) { // go backwards, "most recent first" for (int i = stack.size() - 1; i >= 0; --i) { DebugFrame frame = stack.get(i); + System.out.println("[luceedebug] getCfStack: frame[" + i + "] line=" + frame.getLine() + " source=" + frame.getSourceFilePath()); if (frame.getLine() == 0) { - // ???? should we just not push such frames on the stack? - // what does this mean? + // Frame line not yet set - step notification hasn't run yet + // This can happen when breakpoint fires before first line executes + System.out.println("[luceedebug] getCfStack: skipping frame with line=0"); continue; } else { @@ -607,14 +599,18 @@ public void clearStepRequest(Thread thread) { } public void luceedebug_stepNotificationEntry_step(int lineNumber) { - // Fast path: single volatile read when not stepping (99.9% of the time) + Thread currentThread = Thread.currentThread(); + + // ALWAYS update the frame's line number, even when not stepping + // This is required for breakpoints to work - they need to know the current line + DebugFrame frame = maybeUpdateTopmostFrame(currentThread, lineNumber); + + // Fast path: if not stepping, we're done after updating line number if (!hasAnyStepRequests) { return; } final int minDistanceToLuceedebugStepNotificationEntryFrame = 0; - Thread currentThread = Thread.currentThread(); - DebugFrame frame = maybeUpdateTopmostFrame(currentThread, lineNumber); // should be "definite update topmost frame", we 100% expect there to be a frame CfStepRequest request = stepRequestByThread.get(currentThread); if (request == null) { @@ -724,6 +720,8 @@ private DebugFrame getTopmostFrame(Thread thread) { } public void pushCfFrame(PageContext pageContext, String sourceFilePath) { + Thread t = Thread.currentThread(); + System.out.println("[luceedebug] pushCfFrame: thread=" + t.getName() + " (id=" + t.getId() + ") identity=" + System.identityHashCode(t) + " file=" + sourceFilePath); maybe_pushCfFrame_worker(pageContext, sourceFilePath); } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java index 2db9ea4..cf75d2e 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java @@ -462,19 +462,6 @@ public LuceeVm(Config config, VirtualMachine vm) { while (!done.get()); // about ~8ms to queueWork + wait for work to complete }); - // Register native breakpoint suspend callback (Lucee7+) - NativeDebuggerListener.setOnNativeSuspendCallback((javaThreadId, label) -> { - if (nativeBreakpointEventCallback != null) { - nativeBreakpointEventCallback.accept(javaThreadId, label); - } - }); - - // Register native step callback (Lucee7+) - NativeDebuggerListener.setOnNativeStepCallback(javaThreadId -> { - if (stepEventCallback != null) { - stepEventCallback.accept(javaThreadId); - } - }); } /** @@ -741,7 +728,10 @@ public ThreadInfo[] getThreadListing() { public IDebugFrame[] getStackTrace(long jdwpThreadId) { var thread = threadMap_.getThreadByJdwpIdOrFail(new JdwpThreadID(jdwpThreadId)); - return GlobalIDebugManagerHolder.debugManager.getCfStack(thread); + System.out.println("[luceedebug] getStackTrace: jdwpThreadId=" + jdwpThreadId + " -> thread=" + thread.getName() + " (id=" + thread.getId() + ") identity=" + System.identityHashCode(thread)); + var frames = GlobalIDebugManagerHolder.debugManager.getCfStack(thread); + System.out.println("[luceedebug] getStackTrace: returning " + frames.length + " frames"); + return frames; } public IDebugEntity[] getScopes(long frameID) { @@ -819,25 +809,18 @@ private BpLineAndId[] freshBpLineAndIdRecordsFromLines(RawIdePath idePath, Canon } public IBreakpoint[] bindBreakpoints(RawIdePath idePath, CanonicalServerAbsPath serverPath, int[] lines, String[] exprs) { - // Register native breakpoints (Lucee7+) - // Clear existing native breakpoints for this file first - NativeDebuggerListener.clearBreakpointsForFile(serverPath.get()); - for (int line : lines) { - NativeDebuggerListener.addBreakpoint(serverPath.get(), line); - } - - // In native-only mode, skip JDWP breakpoint registration entirely - if (NativeDebuggerListener.isNativeOnlyMode()) { - // Return unbound breakpoints - native breakpoints don't have JDWP binding info + if (NativeDebuggerListener.isNativeMode()) { + NativeDebuggerListener.clearBreakpointsForFile(serverPath.get()); + for (int line : lines) { + NativeDebuggerListener.addBreakpoint(serverPath.get(), line); + } var lineInfo = freshBpLineAndIdRecordsFromLines(idePath, serverPath, lines, exprs); IBreakpoint[] result = new Breakpoint[lineInfo.length]; for (int i = 0; i < lineInfo.length; i++) { - // Mark as bound since native breakpoints are always "bound" (no class loading dependency) result[i] = Breakpoint.Bound(lineInfo[i].line, lineInfo[i].id); } return result; } - return __internal__bindBreakpoints(serverPath, freshBpLineAndIdRecordsFromLines(idePath, serverPath, lines, exprs)); } @@ -967,14 +950,10 @@ private void clearExistingBreakpoints(CanonicalServerAbsPath absPath) { } public void clearAllBreakpoints() { - // Clear native breakpoints (Lucee7+) - NativeDebuggerListener.clearAllBreakpoints(); - - // In native-only mode, skip JDWP operations - if (NativeDebuggerListener.isNativeOnlyMode()) { + if (NativeDebuggerListener.isNativeMode()) { + NativeDebuggerListener.clearAllBreakpoints(); return; } - replayableBreakpointRequestsByAbsPath_.clear(); vm_.eventRequestManager().deleteAllBreakpoints(); } @@ -1016,10 +995,10 @@ private void continue_(ThreadReference threadRef) { } public void continueAll() { - // Resume all natively suspended threads (Lucee7+ native breakpoints) - NativeDebuggerListener.resumeAllNativeThreads(); - - // Resume all JDWP suspended threads + if (NativeDebuggerListener.isNativeMode()) { + NativeDebuggerListener.resumeAllNativeThreads(); + return; + } // avoid concurrent modification exceptions, calling continue_ mutates `suspendedThreads` Arrays // TODO: Set.toArray(sz -> new T[sz]) is not typesafe, changing the type of Set @@ -1042,14 +1021,10 @@ public void stepIn(long jdwpThreadID) { } public void continue_(long threadID) { - // First, try to resume as a natively suspended thread (Lucee7+ native breakpoints) - // Native breakpoints use Java thread IDs directly - if (NativeDebuggerListener.resumeNativeThread(threadID)) { - System.out.println("[luceedebug] Resumed natively suspended thread: " + threadID); + if (NativeDebuggerListener.isNativeMode()) { + NativeDebuggerListener.resumeNativeThread(threadID); return; } - - // Fall back to JDWP resume continue_(new JdwpThreadID(threadID)); } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java index 72faa6f..9486d91 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java @@ -147,10 +147,9 @@ private static class CachedExecutableLines { private static volatile Consumer onNativeExceptionCallback = null; /** - * Flag to indicate native-only mode (no JDWP breakpoints). - * When true, only native breakpoints are used. + * Native mode flag - when true, use Lucee's DebuggerRegistry API. */ - private static volatile boolean nativeOnlyMode = false; + private static volatile boolean nativeMode = false; /** * Flag indicating a DAP client is actually connected. @@ -242,18 +241,18 @@ private static class StepState { } /** - * Enable native-only mode (skip JDWP breakpoint registration). + * Cached reflection method for DebuggerFrame.getLine(). + * Initialized lazily on first use in getStackDepth(). */ - public static void setNativeOnlyMode(boolean enabled) { - nativeOnlyMode = enabled; - Log.info("Native-only mode: " + enabled); + private static volatile java.lang.reflect.Method debuggerFrameGetLineMethod = null; + + public static void setNativeMode(boolean enabled) { + nativeMode = enabled; + Log.info("Native mode: " + enabled); } - /** - * Check if native-only mode is enabled. - */ - public static boolean isNativeOnlyMode() { - return nativeOnlyMode; + public static boolean isNativeMode() { + return nativeMode; } /** @@ -480,10 +479,15 @@ public static PageContext getAnyPageContext() { */ public static PageContext getPageContext(long javaThreadId) { WeakReference ref = nativelySuspendedThreads.get(javaThreadId); - if (ref != null) { - return ref.get(); + if (ref == null) { + Log.warn("getPageContext: thread " + javaThreadId + " not in map! Map=" + nativelySuspendedThreads.keySet()); + return null; } - return null; + PageContext pc = ref.get(); + if (pc == null) { + Log.warn("getPageContext: PageContext for thread " + javaThreadId + " was GC'd!"); + } + return pc; } /** @@ -502,19 +506,23 @@ public static SuspendLocation getSuspendLocation(long javaThreadId) { * @return true if the thread was found and resumed, false otherwise */ public static boolean resumeNativeThread(long javaThreadId) { + Log.info("resumeNativeThread: thread=" + javaThreadId + ", map=" + nativelySuspendedThreads.keySet()); WeakReference pcRef = nativelySuspendedThreads.remove(javaThreadId); if (pcRef == null) { + Log.warn("resumeNativeThread: thread " + javaThreadId + " not in map!"); return false; } PageContext pc = pcRef.get(); if (pc == null) { + Log.warn("resumeNativeThread: PageContext for thread " + javaThreadId + " was GC'd!"); return false; } - Log.debug("Resuming thread: " + javaThreadId); + Log.info("resumeNativeThread: calling debuggerResume() for thread " + javaThreadId); try { // Call debuggerResume() via reflection (Lucee7+ method) java.lang.reflect.Method resumeMethod = pc.getClass().getMethod("debuggerResume"); resumeMethod.invoke(pc); + Log.info("resumeNativeThread: debuggerResume() completed for thread " + javaThreadId); return true; } catch (NoSuchMethodException e) { Log.error("debuggerResume() not available (pre-Lucee7?)"); @@ -602,12 +610,29 @@ public static void stopStepping(long threadId) { /** * Get the current stack depth for a PageContext. * Uses reflection to get debugger frames. + * Only counts frames with line > 0 to match NativeDebugFrame.getNativeFrames() filtering. */ public static int getStackDepth(PageContext pc) { try { java.lang.reflect.Method getFrames = pc.getClass().getMethod("getDebuggerFrames"); Object[] frames = (Object[]) getFrames.invoke(pc); - return frames != null ? frames.length : 0; + if (frames == null || frames.length == 0) return 0; + + // Cache the getLine method on first use + java.lang.reflect.Method getLine = debuggerFrameGetLineMethod; + if (getLine == null) { + getLine = frames[0].getClass().getMethod("getLine"); + debuggerFrameGetLineMethod = getLine; + } + + // Count only frames with line > 0 (matching getNativeFrames filtering) + // Frames start with line=0 before first ExecutionLog.start() call + int count = 0; + for (Object frame : frames) { + int line = (int) getLine.invoke(frame); + if (line > 0) count++; + } + return count; } catch (Exception e) { // Log error - silent failure could cause incorrect step behavior Log.error("Error getting stack depth: " + e.getMessage()); @@ -623,7 +648,7 @@ public static int getStackDepth(PageContext pc) { */ public static void onSuspend(PageContext pc, String file, int line, String label) { long threadId = Thread.currentThread().getId(); - Log.debug("Suspend: thread=" + threadId + " file=" + Config.shortenPath(file) + " line=" + line + " label=" + label); + Log.info("onSuspend: thread=" + threadId + " file=" + Config.shortenPath(file) + " line=" + line); // Check if we were stepping BEFORE clearing state StepState stepState = steppingThreads.remove(threadId); @@ -641,6 +666,7 @@ public static void onSuspend(PageContext pc, String file, int line, String label // Track the suspended thread so we can resume it later // We store PageContext (not PageContextImpl) to avoid class loading cycles nativelySuspendedThreads.put(threadId, new WeakReference<>(pc)); + Log.info("onSuspend: added thread " + threadId + " to map, map=" + nativelySuspendedThreads.keySet()); // Store suspend location for stack trace (needed when no native DebuggerFrames exist) // Include the exception if we're suspending due to one @@ -1022,8 +1048,8 @@ public static int[] getExecutableLines( String absolutePath ) { return new int[0]; } catch ( NoSuchMethodException e ) { - // Method doesn't exist - Lucee version without this feature - Log.debug( "getExecutableLines: method not found (old Lucee version?)" ); + // Method only exists in debug-compiled classes - this shouldn't happen in debugger mode + Log.error( "getExecutableLines: compiled class for [" + absolutePath + "] is missing debug info (should be auto-generated in debugger mode)" ); return new int[0]; } catch ( java.lang.reflect.InvocationTargetException e ) { diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java index 0dc035c..7a6001a 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java @@ -52,8 +52,8 @@ public static void setLuceeClassLoader(ClassLoader cl) { public NativeLuceeVm(Config config) { this.config_ = config; - // Enable native-only mode - NativeDebuggerListener.setNativeOnlyMode(true); + // Enable native mode + NativeDebuggerListener.setNativeMode(true); // Register native breakpoint suspend callback NativeDebuggerListener.setOnNativeSuspendCallback((javaThreadId, label) -> { @@ -336,10 +336,11 @@ public void stepOut(long threadID) { /** * Get the current stack depth for a thread using native debugger frames. + * Uses NativeDebuggerListener.getStackDepth() to count only real frames (not synthetic). */ private int getStackDepthForThread(long threadID) { - IDebugFrame[] frames = getStackTrace(threadID); - return frames != null ? frames.length : 0; + PageContext pc = NativeDebuggerListener.getPageContext(threadID); + return pc != null ? NativeDebuggerListener.getStackDepth(pc) : 0; } // ========== Debug utilities ========== diff --git a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java index 6c6f526..bea7182 100644 --- a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java +++ b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java @@ -22,6 +22,9 @@ public class ExtensionActivator { private static ClassLoader extensionLoader; private static boolean listenerRegistered = false; private static boolean alreadyActivated = false; + // Keep a static reference to prevent GC from collecting the instance + // (Lucee's startup-hook discards its reference immediately) + private static ExtensionActivator instance; /** * Constructor called by Lucee's startup-hook mechanism. @@ -34,40 +37,69 @@ public ExtensionActivator(Config luceeConfig) { return; } alreadyActivated = true; + // Keep a reference to prevent GC + instance = this; - // Get debug port - if not set, debugger is disabled - int debugPort = EnvUtil.getDebuggerPort(); - if (debugPort < 0) { - Log.info("Debugger disabled - set LUCEE_DEBUGGER_SECRET to enable"); - return; - } - Log.info("Extension activating"); + try { + // Get debug port - if not set, debugger is disabled + int debugPort = EnvUtil.getDebuggerPort(); + if (debugPort < 0) { + Log.info("Debugger disabled - set LUCEE_DEBUGGER_SECRET to enable"); + return; + } + Log.info("Extension activating"); - // Store classloaders for later listener registration - extensionLoader = this.getClass().getClassLoader(); - luceeLoader = luceeConfig.getClass().getClassLoader(); + // Store classloaders for later listener registration + extensionLoader = this.getClass().getClassLoader(); + luceeLoader = luceeConfig.getClass().getClassLoader(); - // Determine filesystem case sensitivity from Lucee's config location - String configPath = luceeConfig.getConfigDir().getAbsolutePath(); - boolean fsCaseSensitive = luceedebug.Config.checkIfFileSystemIsCaseSensitive(configPath); + // Determine filesystem case sensitivity from Lucee's config location + String configPath = luceeConfig.getConfigDir().getAbsolutePath(); + boolean fsCaseSensitive = luceedebug.Config.checkIfFileSystemIsCaseSensitive(configPath); - // Create luceedebug config - luceedebug.Config config = new luceedebug.Config(fsCaseSensitive); + // Create luceedebug config + luceedebug.Config config = new luceedebug.Config(fsCaseSensitive); - // Set Lucee classloader for reflection access to core classes - NativeLuceeVm.setLuceeClassLoader(luceeLoader); + // Set Lucee classloader for reflection access to core classes + NativeLuceeVm.setLuceeClassLoader(luceeLoader); - // Create NativeLuceeVm - luceeVm = new NativeLuceeVm(config); + // Create NativeLuceeVm + luceeVm = new NativeLuceeVm(config); - // Start DAP server in background thread (createForSocket blocks forever) - // Listener registration is deferred until DAP client connects with secret - final int port = debugPort; - new Thread(() -> { - DapServer.createForSocket(luceeVm, config, "localhost", port); - }, "luceedebug-dap-server").start(); + // Start DAP server in background thread (createForSocket blocks forever) + // Listener registration is deferred until DAP client connects with secret + final int port = debugPort; + final luceedebug.Config finalConfig = config; + Thread dapThread = new Thread(() -> { + // Use System.out directly to ensure we see output even if Log class has issues + System.out.println("[luceedebug] DAP server thread started"); + System.out.flush(); + try { + System.out.println("[luceedebug] Calling DapServer.createForSocket on port " + port); + System.out.flush(); + DapServer.createForSocket(luceeVm, finalConfig, "localhost", port); + // This line should never be reached - createForSocket loops forever + System.out.println("[luceedebug] DAP server createForSocket returned unexpectedly"); + } catch (Throwable t) { + System.out.println("[luceedebug] DAP server thread failed: " + t.getClass().getName() + ": " + t.getMessage()); + t.printStackTrace(System.out); + Log.error("DAP server thread failed", t); + } + System.out.println("[luceedebug] DAP server thread exiting"); + }, "luceedebug-dap-server"); + dapThread.setDaemon(true); + dapThread.setUncaughtExceptionHandler((t, e) -> { + System.out.println("[luceedebug] DAP thread died with uncaught exception: " + e.getClass().getName() + ": " + e.getMessage()); + e.printStackTrace(System.out); + }); + dapThread.start(); - Log.info("DAP server starting on localhost:" + debugPort + " (waiting for client with secret)"); + Log.info("Native mode, DAP server starting on localhost:" + debugPort); + } catch (Throwable t) { + System.out.println("[luceedebug] Extension activation failed: " + t.getClass().getName() + ": " + t.getMessage()); + t.printStackTrace(System.out); + Log.error("Extension activation failed", t); + } } /** @@ -111,6 +143,14 @@ public static boolean isListenerRegistered() { return listenerRegistered; } + /** + * Check if the extension was actually activated by Lucee (native mode). + * In agent mode, the class exists but wasn't activated via startup-hook. + */ + public static boolean isNativeModeActive() { + return alreadyActivated; + } + /** * Enable DebuggerExecutionLog via ConfigAdmin. * This triggers template recompilation with exeLogStart()/exeLogEnd() bytecode @@ -273,12 +313,8 @@ private static boolean registerNativeDebuggerListener(ClassLoader luceeLoader, C } } - /** - * Called by Lucee when the extension is uninstalled or updated. - * Shuts down the DAP server to free the port. - */ - public void finalize() { - Log.debug("Extension finalizing - shutting down DAP server"); - DapServer.shutdown(); - } + // Note: finalize() was removed - it was being called by GC prematurely + // and shutting down the DAP server. Now we keep a static reference to + // prevent GC, and rely on DapServer.shutdown() being called explicitly + // (via system properties) when the extension is reinstalled. } diff --git a/test/cfml/AuthTest.cfc b/test/cfml/AuthTest.cfc new file mode 100644 index 0000000..5a5afce --- /dev/null +++ b/test/cfml/AuthTest.cfc @@ -0,0 +1,87 @@ +/** + * Tests for DAP authentication - verifies proper errors when attach() not called. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + function afterEach() { + teardownDap(); + } + + // ========== Auth Tests ========== + + /** + * Verify that setBreakpoints returns error when attach() not called. + */ + function testSetBreakpointsWithoutAttachReturnsError() { + setupDap( attach = false ); + + var threw = false; + var errorMessage = ""; + try { + dap.setBreakpoints( "/some/file.cfm", [ 10 ] ); + } catch ( any e ) { + threw = true; + errorMessage = e.message; + } + + expect( threw ).toBeTrue( "setBreakpoints should throw when not attached" ); + expect( errorMessage ).toInclude( "Not authorized" ); + } + + /** + * Verify that threads returns error when attach() not called. + */ + function testThreadsWithoutAttachReturnsError() { + setupDap( attach = false ); + + var threw = false; + var errorMessage = ""; + try { + dap.threads(); + } catch ( any e ) { + threw = true; + errorMessage = e.message; + } + + expect( threw ).toBeTrue( "threads should throw when not attached" ); + expect( errorMessage ).toInclude( "Not authorized" ); + } + + /** + * Verify that attach with wrong secret returns error. + */ + function testAttachWithWrongSecretReturnsError() { + setupDap( attach = false ); + + var threw = false; + var errorMessage = ""; + try { + dap.attach( "wrong-secret-value" ); + } catch ( any e ) { + threw = true; + errorMessage = e.message; + } + + expect( threw ).toBeTrue( "attach with wrong secret should throw" ); + expect( errorMessage ).toInclude( "Invalid" ); + } + + /** + * Verify that attach with correct secret works. + */ + function testAttachWithCorrectSecretSucceeds() { + setupDap( attach = false ); + + var threw = false; + try { + dap.attach( variables.dapSecret ); + } catch ( any e ) { + threw = true; + } + + expect( threw ).toBeFalse( "attach with correct secret should succeed" ); + } + +} diff --git a/test/cfml/BreakpointLocationsTest.cfc b/test/cfml/BreakpointLocationsTest.cfc new file mode 100644 index 0000000..1159d70 --- /dev/null +++ b/test/cfml/BreakpointLocationsTest.cfc @@ -0,0 +1,126 @@ +/** + * Tests for breakpointLocations request (valid breakpoint lines in a file). + * + * This is a native-only feature that uses Page.getExecutableLines() from Lucee core. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "breakpoint-target.cfm" ); + } + + function afterAll() { + teardownDap(); + } + + // ========== Basic Breakpoint Locations ========== + + function testBreakpointLocationsReturnsResults() skip="notSupportsBreakpointLocations" { + var response = dap.breakpointLocations( variables.targetFile, 1 ); + + expect( response.body ).toHaveKey( "breakpoints" ); + expect( response.body.breakpoints ).toBeArray(); + } + + function testBreakpointLocationsForSingleLine() skip="notSupportsBreakpointLocations" { + // Line 12 is inside simpleFunction - should be executable + var response = dap.breakpointLocations( variables.targetFile, 12 ); + + expect( response.body.breakpoints.len() ).toBeGTE( 1, "Line 12 should be executable" ); + + // Verify the returned location + var location = response.body.breakpoints[ 1 ]; + expect( location.line ).toBe( 12 ); + } + + function testBreakpointLocationsForRange() skip="notSupportsBreakpointLocations" { + // Request locations for lines 10-20 (covers simpleFunction and conditionalFunction) + var response = dap.breakpointLocations( variables.targetFile, 10, 20 ); + + expect( response.body.breakpoints.len() ).toBeGT( 1, "Should have multiple executable lines in range" ); + + // All returned lines should be within the requested range + for ( var loc in response.body.breakpoints ) { + expect( loc.line ).toBeGTE( 10, "Line should be >= 10" ); + expect( loc.line ).toBeLTE( 20, "Line should be <= 20" ); + } + } + + // ========== Non-Executable Lines ========== + + function testBreakpointLocationsEmptyForCommentLine() skip="notSupportsBreakpointLocations" { + // Lines 1-9 are comments/function declaration - may not all be executable + // Line 3 specifically is a comment line + var response = dap.breakpointLocations( variables.targetFile, 3, 3 ); + + // Comment lines should not be executable + // Either empty or the response adjusts to nearest executable line + if ( response.body.breakpoints.len() > 0 ) { + // If it returns a location, it should be adjusted to a different line + expect( response.body.breakpoints[ 1 ].line ).notToBe( 3, "Comment line should not be directly executable" ); + } + } + + function testBreakpointLocationsEmptyForBlankLine() skip="notSupportsBreakpointLocations" { + // Test a blank line (if there is one in the file) + // For breakpoint-target.cfm, let's check line 28 area + var response = dap.breakpointLocations( variables.targetFile, 28, 28 ); + + // This is the last line with content - should be executable or empty + // The test is mainly to ensure the request doesn't error + expect( response.body ).toHaveKey( "breakpoints" ); + } + + // ========== Executable Lines Verification ========== + + function testAllReturnedLocationsAreValid() skip="notSupportsBreakpointLocations" { + // Get all locations in the file + var response = dap.breakpointLocations( variables.targetFile, 1, 50 ); + + // Each returned location should allow setting a breakpoint + for ( var loc in response.body.breakpoints ) { + var bpResponse = dap.setBreakpoints( variables.targetFile, [ loc.line ] ); + expect( bpResponse.body.breakpoints[ 1 ].verified ).toBeTrue( + "Breakpoint at line #loc.line# should be verified" + ); + } + + // Clean up + dap.setBreakpoints( variables.targetFile, [] ); + } + + // ========== Different File Types ========== + + function testBreakpointLocationsForSteppingTarget() skip="notSupportsBreakpointLocations" { + var steppingFile = getArtifactPath( "stepping-target.cfm" ); + var response = dap.breakpointLocations( steppingFile, 1, 30 ); + + expect( response.body.breakpoints.len() ).toBeGT( 0, "Should have executable lines" ); + + // Lines inside functions should be executable + var lines = response.body.breakpoints.map( function( loc ) { return loc.line; } ); + + // Line 12 (inside innerFunc) should be executable + expect( lines ).toInclude( 12, "Line 12 in innerFunc should be executable" ); + + // Line 17 (inside outerFunc) should be executable + expect( lines ).toInclude( 17, "Line 17 in outerFunc should be executable" ); + } + + function testBreakpointLocationsForVariablesTarget() skip="notSupportsBreakpointLocations" { + var variablesFile = getArtifactPath( "variables-target.cfm" ); + var response = dap.breakpointLocations( variablesFile, 1, 50 ); + + expect( response.body.breakpoints.len() ).toBeGT( 0, "Should have executable lines" ); + + // Line 35 (debugLine assignment) should be executable + var lines = response.body.breakpoints.map( function( loc ) { return loc.line; } ); + expect( lines ).toInclude( 35, "Line 35 should be executable" ); + } + +} diff --git a/test/cfml/BreakpointsTest.cfc b/test/cfml/BreakpointsTest.cfc new file mode 100644 index 0000000..346a638 --- /dev/null +++ b/test/cfml/BreakpointsTest.cfc @@ -0,0 +1,147 @@ +/** + * Tests for basic breakpoint functionality. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in breakpoint-target.cfm - keep in sync with the file + // These are validated in testValidateLineNumbers() using breakpointLocations (native mode only) + variables.lines = { + conditionalFunctionStart: 12, // var result = 0; + ifBlockBody: 14, // result = arguments.value * 2; + elseBlockBody: 16, // result = arguments.value + 5; + writeOutput: 24 // writeOutput( "Done: ..." ); + }; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "breakpoint-target.cfm" ); + } + + // Validate our line number assumptions using breakpointLocations (native mode only) + function testValidateLineNumbers_breakpointTarget() skip="notSupportsBreakpointLocations" { + var locations = dap.breakpointLocations( variables.targetFile, 1, 30 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location" ); + } + } + + function afterEach() { + clearBreakpoints( variables.targetFile ); + } + + // ========== Basic Breakpoints ========== + + function testSetBreakpointReturnsVerified() { + var response = dap.setBreakpoints( variables.targetFile, [ lines.conditionalFunctionStart ] ); + + expect( response.body ).toHaveKey( "breakpoints" ); + expect( response.body.breakpoints ).toHaveLength( 1 ); + expect( response.body.breakpoints[ 1 ].verified ).toBeTrue( "Breakpoint should be verified" ); + } + + function testSetMultipleBreakpoints() { + var response = dap.setBreakpoints( variables.targetFile, [ + lines.conditionalFunctionStart, + lines.ifBlockBody, + lines.writeOutput + ] ); + + expect( response.body.breakpoints ).toHaveLength( 3 ); + for ( var bp in response.body.breakpoints ) { + expect( bp.verified ).toBeTrue( "All breakpoints should be verified" ); + } + } + + function testBreakpointHits() { + var bpResponse = dap.setBreakpoints( variables.targetFile, [ lines.conditionalFunctionStart ] ); + + systemOutput( "testBreakpointHits: targetFile=#variables.targetFile#", true ); + systemOutput( "testBreakpointHits: response=#serializeJSON( bpResponse )#", true ); + + expect( bpResponse.body.breakpoints ).toHaveLength( 1, "Should have 1 breakpoint" ); + expect( bpResponse.body.breakpoints[ 1 ].verified ).toBeTrue( "Breakpoint should be verified" ); + + triggerArtifact( "breakpoint-target.cfm" ); + sleep( 500 ); + systemOutput( "testBreakpointHits: httpResult=#serializeJSON( variables.httpResult )#", true ); + expect( variables.httpResult ).notToHaveKey( "error" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "breakpoint" ); + + var frame = getTopFrame( stopped.body.threadId ); + expect( frame.line ).toBe( lines.conditionalFunctionStart ); + + cleanupThread( stopped.body.threadId ); + } + + function testClearBreakpoints() { + dap.setBreakpoints( variables.targetFile, [ lines.conditionalFunctionStart, lines.ifBlockBody ] ); + + var response = dap.setBreakpoints( variables.targetFile, [] ); + + expect( response.body.breakpoints ).toHaveLength( 0 ); + } + + function testReplaceBreakpoints() { + dap.setBreakpoints( variables.targetFile, [ lines.conditionalFunctionStart, lines.ifBlockBody ] ); + + var response = dap.setBreakpoints( variables.targetFile, [ lines.writeOutput ] ); + + expect( response.body.breakpoints ).toHaveLength( 1 ); + expect( response.body.breakpoints[ 1 ].line ).toBe( lines.writeOutput ); + } + + // ========== Conditional Breakpoints ========== + + function testConditionalBreakpointHits() skip="notSupportsConditionalBreakpoints" { + // Set conditional breakpoint on if block - only hit when value > 10 + dap.setBreakpoints( variables.targetFile, [ lines.ifBlockBody ], [ "arguments.value > 10" ] ); + + // Trigger - conditionalFunction is called with 15 + triggerArtifact( "breakpoint-target.cfm" ); + sleep( 500 ); + systemOutput( "testConditionalBreakpointHits: httpResult=#serializeJSON( variables.httpResult )#", true ); + expect( variables.httpResult ).notToHaveKey( "error" ); + + // Should hit because 15 > 10 + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "breakpoint" ); + + var frame = getTopFrame( stopped.body.threadId ); + expect( frame.line ).toBe( lines.ifBlockBody ); + + cleanupThread( stopped.body.threadId ); + } + + function testConditionalBreakpointSkipsWhenFalse() skip="notSupportsConditionalBreakpoints" { + // Set conditional breakpoint that should NOT hit + dap.setBreakpoints( variables.targetFile, [ lines.ifBlockBody ], [ "arguments.value > 100" ] ); + + // Trigger - conditionalFunction is called with 15 + triggerArtifact( "breakpoint-target.cfm" ); + sleep( 500 ); + systemOutput( "testConditionalBreakpointSkipsWhenFalse: httpResult=#serializeJSON( variables.httpResult )#", true ); + expect( variables.httpResult ).notToHaveKey( "error" ); + + // Should NOT hit because 15 is not > 100 + sleep( 2000 ); + + var hasStoppedEvent = dap.hasEvent( "stopped" ); + expect( hasStoppedEvent ).toBeFalse( "Should not stop when condition is false" ); + + waitForHttpComplete(); + } + +} diff --git a/test/cfml/CompletionsTest.cfc b/test/cfml/CompletionsTest.cfc new file mode 100644 index 0000000..9ddb2de --- /dev/null +++ b/test/cfml/CompletionsTest.cfc @@ -0,0 +1,202 @@ +/** + * Tests for debug console completions/autocomplete functionality. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in completions-target.cfm - keep in sync with the file + // These are validated in testValidateLineNumbers() using breakpointLocations (native mode only) + variables.lines = { + debugLine: 22 // var stopHere = true; + }; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "completions-target.cfm" ); + } + + // Validate our line number assumptions using breakpointLocations (native mode only) + function testValidateLineNumbers_completionsTarget() skip="notSupportsBreakpointLocations" { + var locations = dap.breakpointLocations( variables.targetFile, 1, 35 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location" ); + } + } + + function afterEach() { + clearBreakpoints( variables.targetFile ); + } + + // ========== Basic Completions ========== + + function testCompletionsReturnsResults() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for "my" + var completionsResponse = dap.completions( frame.id, "my" ); + + expect( completionsResponse.body ).toHaveKey( "targets" ); + expect( completionsResponse.body.targets.len() ).toBeGT( 0, "Should return completions" ); + + cleanupThread( threadId ); + } + + function testCompletionsForLocalVariables() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for "my" - should include myString, myNumber, myStruct, etc. + var completionsResponse = dap.completions( frame.id, "my" ); + var targets = completionsResponse.body.targets; + + var labels = targets.map( function( t ) { return t.label; } ); + + expect( labels ).toInclude( "myString" ); + expect( labels ).toInclude( "myNumber" ); + expect( labels ).toInclude( "myStruct" ); + expect( labels ).toInclude( "myArray" ); + + cleanupThread( threadId ); + } + + // ========== Dot-Notation Completions ========== + + function testCompletionsForStructKeys() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for "myStruct." - should show struct keys + var completionsResponse = dap.completions( frame.id, "myStruct." ); + var targets = completionsResponse.body.targets; + + var labels = targets.map( function( t ) { return t.label; } ); + + expect( labels ).toInclude( "firstName" ); + expect( labels ).toInclude( "lastName" ); + expect( labels ).toInclude( "address" ); + + cleanupThread( threadId ); + } + + function testCompletionsForNestedStructKeys() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for "myStruct.address." - should show nested keys + var completionsResponse = dap.completions( frame.id, "myStruct.address." ); + var targets = completionsResponse.body.targets; + + var labels = targets.map( function( t ) { return t.label; } ); + + expect( labels ).toInclude( "street" ); + expect( labels ).toInclude( "city" ); + + cleanupThread( threadId ); + } + + // ========== Partial Completions ========== + + function testCompletionsWithPartialInput() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for "myStruct.f" - should filter to firstName + var completionsResponse = dap.completions( frame.id, "myStruct.f" ); + var targets = completionsResponse.body.targets; + + var labels = targets.map( function( t ) { return t.label; } ); + + expect( labels ).toInclude( "firstName" ); + // lastName shouldn't be included (doesn't start with 'f') + expect( labels ).notToInclude( "lastName" ); + + cleanupThread( threadId ); + } + + // ========== Component Method Completions ========== + + function testCompletionsForComponentMethods() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for "myComponent." - should show component methods + var completionsResponse = dap.completions( frame.id, "myComponent." ); + var targets = completionsResponse.body.targets; + + var labels = targets.map( function( t ) { return t.label; } ); + + // DapClient has methods like connect, disconnect, initialize, etc. + expect( labels ).toInclude( "connect" ); + expect( labels ).toInclude( "disconnect" ); + + cleanupThread( threadId ); + } + + // ========== Empty/Invalid Input ========== + + function testCompletionsWithEmptyInput() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for empty string - should return local variables + var completionsResponse = dap.completions( frame.id, "" ); + var targets = completionsResponse.body.targets; + + // Should have some completions (local variables, etc.) + expect( targets.len() ).toBeGT( 0, "Empty input should return completions" ); + + cleanupThread( threadId ); + } + +} diff --git a/test/cfml/ConsoleOutputTest.cfc b/test/cfml/ConsoleOutputTest.cfc new file mode 100644 index 0000000..30c7e94 --- /dev/null +++ b/test/cfml/ConsoleOutputTest.cfc @@ -0,0 +1,125 @@ +/** + * Tests for console output streaming (systemOutput to debug console). + * Native mode only - requires consoleOutput attach option. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in console-output-target.cfm - keep in sync with the file + // These are validated in testValidateLineNumbers() using breakpointLocations (native mode only) + variables.lines = { + debugLine: 14 // var stopHere = true; + }; + + function beforeAll() { + // Enable consoleOutput for these tests + setupDap( consoleOutput = true ); + variables.targetFile = getArtifactPath( "console-output-target.cfm" ); + } + + // Validate our line number assumptions using breakpointLocations (native mode only) + function testValidateLineNumbers_consoleOutputTarget() skip="notSupportsBreakpointLocations" { + var locations = dap.breakpointLocations( variables.targetFile, 1, 25 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location" ); + } + } + + function afterEach() { + clearBreakpoints( variables.targetFile ); + // Drain any remaining output events + dap.drainEvents(); + } + + // ========== Console Output Events ========== + + function testSystemOutputSendsOutputEvent() skip="notNativeMode" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + // Trigger with a unique message + var testMessage = "test-output-#createUUID()#"; + triggerArtifact( "console-output-target.cfm", { message: testMessage } ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Check for output event containing our message + // The systemOutput should have been captured before we hit the breakpoint + var events = dap.drainEvents(); + var foundOutput = false; + + for ( var event in events ) { + if ( event.event == "output" && event.body.output contains testMessage ) { + foundOutput = true; + systemOutput( "Found output event: #serializeJSON( event )#", true ); + break; + } + } + + expect( foundOutput ).toBeTrue( "Should receive output event with test message" ); + + cleanupThread( threadId ); + } + + function testOutputEventHasStdoutCategory() skip="notNativeMode" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + var testMessage = "category-test-#createUUID()#"; + triggerArtifact( "console-output-target.cfm", { message: testMessage } ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var events = dap.drainEvents(); + var outputEvent = {}; + + for ( var event in events ) { + if ( event.event == "output" && event.body.output contains testMessage ) { + outputEvent = event; + break; + } + } + + expect( outputEvent ).toHaveKey( "body" ); + expect( outputEvent.body ).toHaveKey( "category" ); + expect( outputEvent.body.category ).toBe( "stdout" ); + + cleanupThread( threadId ); + } + + function testMultipleOutputEvents() skip="notNativeMode" { + // This test verifies that multiple systemOutput calls each generate events + // The target file only has one systemOutput, so we'll just verify the mechanism works + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + var testMessage = "multi-test-#createUUID()#"; + triggerArtifact( "console-output-target.cfm", { message: testMessage } ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var events = dap.drainEvents(); + var outputCount = 0; + + for ( var event in events ) { + if ( event.event == "output" ) { + outputCount++; + systemOutput( "Output event ##: #outputCount# - #event.body.output#", true ); + } + } + + // Should have at least one output event + expect( outputCount ).toBeGTE( 1, "Should have at least one output event" ); + + cleanupThread( threadId ); + } + +} diff --git a/test/cfml/DapClient.cfc b/test/cfml/DapClient.cfc new file mode 100644 index 0000000..4740623 --- /dev/null +++ b/test/cfml/DapClient.cfc @@ -0,0 +1,433 @@ +/** + * DAP (Debug Adapter Protocol) client for testing luceedebug. + * + * Usage: + * dap = new DapClient(); + * dap.connect( "localhost", 10000 ); + * dap.initialize(); + * dap.setBreakpoints( "/path/to/file.cfm", [ 10, 20 ] ); + * dap.configurationDone(); + * // ... trigger breakpoint via HTTP ... + * event = dap.waitForEvent( "stopped", 2000 ); + * stack = dap.stackTrace( event.body.threadId ); + * dap.continueThread( event.body.threadId ); + * dap.disconnect(); + */ +component { + + variables.socket = javacast( "null", 0 ); + variables.inputStream = javacast( "null", 0 ); + variables.outputStream = javacast( "null", 0 ); + variables.seq = 0; + variables.eventQueue = []; + variables.pendingResponses = {}; + variables.debug = false; + + public function init( boolean debug = false ) { + variables.debug = arguments.debug; + return this; + } + + // ========== Connection ========== + + public function connect( required string host, required numeric port ) { + variables.socket = createObject( "java", "java.net.Socket" ).init( arguments.host, arguments.port ); + variables.socket.setSoTimeout( 100 ); // 100ms read timeout for polling + variables.inputStream = variables.socket.getInputStream(); + variables.outputStream = variables.socket.getOutputStream(); + debugLog( "Connected to #arguments.host#:#arguments.port#" ); + } + + public function disconnect() { + if ( !isNull( variables.socket ) ) { + variables.socket.close(); + variables.socket = javacast( "null", 0 ); + debugLog( "Disconnected" ); + } + } + + public boolean function isConnected() { + return !isNull( variables.socket ) && variables.socket.isConnected() && !variables.socket.isClosed(); + } + + // ========== DAP Commands ========== + + public struct function initialize() { + var response = sendRequest( "initialize", { + "clientID": "cfml-dap-test", + "adapterID": "luceedebug", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true + } ); + return response; + } + + public struct function attach( required string secret, struct pathTransforms = {}, boolean consoleOutput = false ) { + var args = { + "secret": arguments.secret + }; + if ( !structIsEmpty( arguments.pathTransforms ) ) { + args[ "pathTransforms" ] = arguments.pathTransforms; + } + if ( arguments.consoleOutput ) { + args[ "consoleOutput" ] = true; + } + var response = sendRequest( "attach", args ); + // Wait for initialized event from server + waitForEvent( "initialized", 5000 ); + return response; + } + + public struct function setBreakpoints( required string path, required array lines, array conditions = [] ) { + var breakpoints = []; + for ( var i = 1; i <= arguments.lines.len(); i++ ) { + var bp = { "line": arguments.lines[ i ] }; + if ( arguments.conditions.len() >= i && len( arguments.conditions[ i ] ) ) { + bp[ "condition" ] = arguments.conditions[ i ]; + } + breakpoints.append( bp ); + } + var response = sendRequest( "setBreakpoints", { + "source": { "path": arguments.path }, + "breakpoints": breakpoints + } ); + systemOutput( "setBreakpoints: path=#arguments.path# lines=#serializeJSON( arguments.lines )# response=#serializeJSON( response )#", true ); + return response; + } + + public struct function setFunctionBreakpoints( required array names, array conditions = [] ) { + var breakpoints = []; + for ( var i = 1; i <= arguments.names.len(); i++ ) { + var bp = { "name": arguments.names[ i ] }; + if ( arguments.conditions.len() >= i && len( arguments.conditions[ i ] ) ) { + bp[ "condition" ] = arguments.conditions[ i ]; + } + breakpoints.append( bp ); + } + var response = sendRequest( "setFunctionBreakpoints", { + "breakpoints": breakpoints + } ); + return response; + } + + public struct function setExceptionBreakpoints( required array filters ) { + var response = sendRequest( "setExceptionBreakpoints", { + "filters": arguments.filters + } ); + return response; + } + + public struct function breakpointLocations( required string path, required numeric line, numeric endLine = 0 ) { + var args = { + "source": { "path": arguments.path }, + "line": arguments.line + }; + if ( arguments.endLine > 0 ) { + args[ "endLine" ] = arguments.endLine; + } + var response = sendRequest( "breakpointLocations", args ); + return response; + } + + public struct function configurationDone() { + return sendRequest( "configurationDone", {} ); + } + + public struct function threads() { + return sendRequest( "threads", {} ); + } + + public struct function stackTrace( required numeric threadId, numeric startFrame = 0, numeric levels = 20 ) { + return sendRequest( "stackTrace", { + "threadId": arguments.threadId, + "startFrame": arguments.startFrame, + "levels": arguments.levels + } ); + } + + public struct function scopes( required numeric frameId ) { + return sendRequest( "scopes", { + "frameId": arguments.frameId + } ); + } + + public struct function getVariables( required numeric variablesReference ) { + return sendRequest( "variables", { + "variablesReference": arguments.variablesReference + } ); + } + + public struct function setVariable( required numeric variablesReference, required string name, required string value ) { + return sendRequest( "setVariable", { + "variablesReference": arguments.variablesReference, + "name": arguments.name, + "value": arguments.value + } ); + } + + public struct function continueThread( required numeric threadId ) { + systemOutput( "continueThread: threadId=#arguments.threadId#", true ); + var response = sendRequest( "continue", { + "threadId": arguments.threadId + } ); + systemOutput( "continueThread: response=#serializeJSON( response )#", true ); + return response; + } + + public struct function stepOver( required numeric threadId ) { + systemOutput( "stepOver: threadId=#arguments.threadId#", true ); + var response = sendRequest( "next", { + "threadId": arguments.threadId + } ); + systemOutput( "stepOver: response=#serializeJSON( response )#", true ); + return response; + } + + public struct function stepIn( required numeric threadId ) { + systemOutput( "stepIn: threadId=#arguments.threadId#", true ); + var response = sendRequest( "stepIn", { + "threadId": arguments.threadId + } ); + systemOutput( "stepIn: response=#serializeJSON( response )#", true ); + return response; + } + + public struct function stepOut( required numeric threadId ) { + systemOutput( "stepOut: threadId=#arguments.threadId#", true ); + var response = sendRequest( "stepOut", { + "threadId": arguments.threadId + } ); + systemOutput( "stepOut: response=#serializeJSON( response )#", true ); + return response; + } + + public struct function evaluate( required numeric frameId, required string expression, string context = "watch" ) { + return sendRequest( "evaluate", { + "frameId": arguments.frameId, + "expression": arguments.expression, + "context": arguments.context + } ); + } + + public struct function completions( required numeric frameId, required string text, numeric column = 0 ) { + return sendRequest( "completions", { + "frameId": arguments.frameId, + "text": arguments.text, + "column": arguments.column > 0 ? arguments.column : len( arguments.text ) + 1 + } ); + } + + public struct function exceptionInfo( required numeric threadId ) { + return sendRequest( "exceptionInfo", { + "threadId": arguments.threadId + } ); + } + + public struct function dapDisconnect() { + return sendRequest( "disconnect", {} ); + } + + // ========== Event Handling ========== + + /** + * Wait for a specific event type. + * @eventType The event type to wait for (e.g., "stopped", "thread") + * @timeoutMs Maximum time to wait in milliseconds + * @return The event struct, or throws if timeout + */ + public struct function waitForEvent( required string eventType, numeric timeoutMs = 5000 ) { + var startTime = getTickCount(); + + // Give the server a moment to process and send the event + sleep( 50 ); + + while ( getTickCount() - startTime < arguments.timeoutMs ) { + // Poll for new messages first + pollMessages(); + + // Check queued events + for ( var i = 1; i <= variables.eventQueue.len(); i++ ) { + if ( variables.eventQueue[ i ].event == arguments.eventType ) { + var event = variables.eventQueue[ i ]; + variables.eventQueue.deleteAt( i ); + systemOutput( "waitForEvent: found #arguments.eventType# event=#serializeJSON( event )#", true ); + return event; + } + } + + sleep( 10 ); + } + + throw( type="DapClient.Timeout", message="Timeout waiting for event: #arguments.eventType#" ); + } + + /** + * Check if any events of a type are queued. + */ + public boolean function hasEvent( required string eventType ) { + pollMessages(); + for ( var event in variables.eventQueue ) { + if ( event.event == arguments.eventType ) { + return true; + } + } + return false; + } + + /** + * Get all queued events (clears the queue). + */ + public array function drainEvents() { + pollMessages(); + var events = variables.eventQueue; + variables.eventQueue = []; + return events; + } + + // ========== Protocol Implementation ========== + + private struct function sendRequest( required string command, required struct args ) { + var requestSeq = ++variables.seq; + var dapRequest = { + "seq": requestSeq, + "type": "request", + "command": arguments.command, + "arguments": arguments.args + }; + + sendMessage( dapRequest ); + + // Wait for response with matching request_seq + var startTime = getTickCount(); + var timeout = 10000; // 10 second timeout for responses + + while ( getTickCount() - startTime < timeout ) { + pollMessages(); + + if ( variables.pendingResponses.keyExists( requestSeq ) ) { + var response = variables.pendingResponses[ requestSeq ]; + variables.pendingResponses.delete( requestSeq ); + + if ( !response.success ) { + throw( type="DapClient.Error", message="DAP error: #response.message ?: 'unknown'#" ); + } + + return response; + } + + sleep( 10 ); + } + + throw( type="DapClient.Timeout", message="Timeout waiting for response to: #arguments.command#" ); + } + + private void function sendMessage( required struct message ) { + var json = serializeJSON( arguments.message ); + var bytes = json.getBytes( "UTF-8" ); + var CRLF = chr( 13 ) & chr( 10 ); + var header = "Content-Length: #arrayLen( bytes )#" & CRLF & CRLF; + + debugLog( ">>> #json#" ); + + variables.outputStream.write( header.getBytes( "UTF-8" ) ); + variables.outputStream.write( bytes ); + variables.outputStream.flush(); + } + + private void function pollMessages() { + try { + while ( variables.inputStream.available() > 0 ) { + var message = readMessage(); + if ( !isNull( message ) ) { + handleMessage( message ); + } + } + } catch ( any e ) { + // Socket timeout is expected, ignore + if ( !e.message contains "timed out" && !e.message contains "Read timed out" ) { + rethrow; + } + } + } + + private any function readMessage() { + // Read headers until empty line + var headers = {}; + var headerLine = readLine(); + + while ( headerLine != "" ) { + var colonPos = headerLine.find( ":" ); + if ( colonPos > 0 ) { + var key = headerLine.left( colonPos - 1 ).trim(); + var value = headerLine.mid( colonPos + 1, headerLine.len() ).trim(); + headers[ key ] = value; + } + headerLine = readLine(); + } + + if ( !headers.keyExists( "Content-Length" ) ) { + return javacast( "null", 0 ); + } + + // Read body + var contentLength = val( headers[ "Content-Length" ] ); + var bodyBytes = createObject( "java", "java.io.ByteArrayOutputStream" ).init(); + var remaining = contentLength; + + while ( remaining > 0 ) { + var b = variables.inputStream.read(); + if ( b == -1 ) { + throw( type="DapClient.Error", message="Unexpected end of stream" ); + } + bodyBytes.write( b ); + remaining--; + } + + var json = bodyBytes.toString( "UTF-8" ); + debugLog( "<<< #json#" ); + + return deserializeJSON( json ); + } + + private string function readLine() { + var line = createObject( "java", "java.lang.StringBuilder" ).init(); + var prevChar = 0; + + while ( true ) { + var b = variables.inputStream.read(); + if ( b == -1 ) { + break; + } + var c = chr( b ); + if ( c == chr( 10 ) ) { // LF + break; + } + if ( c != chr( 13 ) ) { // Skip CR + line.append( c ); + } + } + + return line.toString(); + } + + private void function handleMessage( required struct message ) { + switch ( arguments.message.type ) { + case "response": + variables.pendingResponses[ arguments.message.request_seq ] = arguments.message; + break; + case "event": + variables.eventQueue.append( arguments.message ); + break; + default: + debugLog( "Unknown message type: #arguments.message.type#" ); + } + } + + private void function debugLog( required string msg ) { + if ( variables.debug ) { + systemOutput( "[DapClient] #arguments.msg#", true ); + } + } + +} diff --git a/test/cfml/DapTestCase.cfc b/test/cfml/DapTestCase.cfc new file mode 100644 index 0000000..8db9ddd --- /dev/null +++ b/test/cfml/DapTestCase.cfc @@ -0,0 +1,222 @@ +/** + * Base test case for DAP (Debug Adapter Protocol) tests. + * + * Provides connection management, feature detection, and helper methods. + * + * Configuration via environment variables: + * DAP_HOST - debugger host (default: localhost) + * DAP_PORT - debugger port (default: 10000) + * DEBUGGEE_HTTP - HTTP URL of debuggee (default: http://localhost:8888) + * DEBUGGEE_ARTIFACT_PATH - filesystem path to artifacts on debuggee (default: same as test runner) + * DAP_DEBUG - enable debug logging (default: false) + */ +component extends="org.lucee.cfml.test.LuceeTestCase" { + + variables.dap = javacast( "null", 0 ); + variables.capabilities = {}; + variables.dapHost = ""; + variables.dapPort = 0; + variables.debuggeeHttp = ""; + variables.debuggeeArtifactPath = ""; + variables.httpThread = ""; + variables.httpResult = {}; + + function beforeAll() { + // Load config from environment + variables.dapHost = server.system.environment.DAP_HOST ?: "localhost"; + variables.dapPort = val( server.system.environment.DAP_PORT ?: 10000 ); + variables.debuggeeHttp = server.system.environment.DEBUGGEE_HTTP ?: "http://localhost:8888"; + variables.debuggeeArtifactPath = server.system.environment.DEBUGGEE_ARTIFACT_PATH ?: ""; + var debug = ( server.system.environment.DAP_DEBUG ?: "false" ) == "true"; + + systemOutput( "DapTestCase: Connecting to #variables.dapHost#:#variables.dapPort#", true ); + systemOutput( "DapTestCase: Debuggee HTTP at #variables.debuggeeHttp#", true ); + if ( len( variables.debuggeeArtifactPath ) ) { + systemOutput( "DapTestCase: Debuggee artifact path: #variables.debuggeeArtifactPath#", true ); + } + + // Connect to DAP server + variables.dap = new DapClient( debug = debug ); + variables.dap.connect( variables.dapHost, variables.dapPort ); + + // Initialize and store capabilities + var initResponse = variables.dap.initialize(); + variables.capabilities = initResponse.body ?: {}; + + systemOutput( "DapTestCase: Capabilities: #serializeJSON( variables.capabilities )#", true ); + + // Send configurationDone + variables.dap.configurationDone(); + } + + function afterAll() { + if ( !isNull( variables.dap ) && variables.dap.isConnected() ) { + try { + variables.dap.dapDisconnect(); + } catch ( any e ) { + // Ignore disconnect errors + } + variables.dap.disconnect(); + } + } + + // ========== Capability Detection ========== + + public struct function getCapabilities() { + return variables.capabilities; + } + + public boolean function supportsConditionalBreakpoints() { + return variables.capabilities.supportsConditionalBreakpoints ?: false; + } + + public boolean function supportsSetVariable() { + return variables.capabilities.supportsSetVariable ?: false; + } + + public boolean function supportsCompletions() { + return variables.capabilities.supportsCompletionsRequest ?: false; + } + + public boolean function supportsFunctionBreakpoints() { + return variables.capabilities.supportsFunctionBreakpoints ?: false; + } + + public boolean function supportsBreakpointLocations() { + return variables.capabilities.supportsBreakpointLocationsRequest ?: false; + } + + public boolean function supportsExceptionInfo() { + return variables.capabilities.supportsExceptionInfoRequest ?: false; + } + + public boolean function supportsEvaluate() { + return variables.capabilities.supportsEvaluateForHovers ?: false; + } + + // ========== Test Helpers ========== + + /** + * Get the server path for a test artifact. + * Uses DEBUGGEE_ARTIFACT_PATH if set, otherwise assumes same location as test runner. + */ + public string function getArtifactPath( required string filename ) { + if ( len( variables.debuggeeArtifactPath ) ) { + return variables.debuggeeArtifactPath & arguments.filename; + } + var testDir = getDirectoryFromPath( getCurrentTemplatePath() ); + return testDir & "artifacts/" & arguments.filename; + } + + /** + * Get the HTTP URL for a test artifact. + */ + public string function getArtifactUrl( required string filename ) { + return variables.debuggeeHttp & "/test/cfml/artifacts/" & arguments.filename; + } + + /** + * Trigger an HTTP request to an artifact in a background thread. + * Use waitForHttpComplete() to wait for completion. + */ + public void function triggerArtifact( required string filename, struct params = {} ) { + var url = getArtifactUrl( arguments.filename ); + var queryString = ""; + + for ( var key in arguments.params ) { + queryString &= ( len( queryString ) ? "&" : "?" ) & encodeForURL( key ) & "=" & encodeForURL( arguments.params[ key ] ); + } + + url &= queryString; + variables.httpResult = {}; + variables.httpThread = "httpTrigger_" & createUUID(); + + thread name="#variables.httpThread#" url=url httpResult=variables.httpResult { + try { + http url="#url#" result="local.r" timeout=60; + httpResult.status = local.r.statusCode; + httpResult.content = local.r.fileContent; + } catch ( any e ) { + httpResult.error = e.message; + } + } + } + + /** + * Wait for the background HTTP request to complete. + */ + public struct function waitForHttpComplete( numeric timeout = 30000 ) { + if ( len( variables.httpThread ) ) { + threadJoin( variables.httpThread, arguments.timeout ); + variables.httpThread = ""; + } + return variables.httpResult; + } + + /** + * Get the top stack frame from a stopped thread. + */ + public struct function getTopFrame( required numeric threadId ) { + var stackResponse = variables.dap.stackTrace( arguments.threadId ); + var frames = stackResponse.body.stackFrames ?: []; + if ( frames.len() == 0 ) { + throw( type="DapTestCase.Error", message="No stack frames available" ); + } + return frames[ 1 ]; + } + + /** + * Get a scope by name from a frame. + */ + public struct function getScopeByName( required numeric frameId, required string scopeName ) { + var scopesResponse = variables.dap.scopes( arguments.frameId ); + var scopes = scopesResponse.body.scopes ?: []; + for ( var scope in scopes ) { + if ( scope.name == arguments.scopeName ) { + return scope; + } + } + throw( type="DapTestCase.Error", message="Scope not found: #arguments.scopeName#" ); + } + + /** + * Get a variable by name from a variables reference. + */ + public struct function getVariableByName( required numeric variablesReference, required string name ) { + var varsResponse = variables.dap.getVariables( arguments.variablesReference ); + var vars = varsResponse.body.variables ?: []; + for ( var v in vars ) { + if ( v.name == arguments.name ) { + return v; + } + } + throw( type="DapTestCase.Error", message="Variable not found: #arguments.name#" ); + } + + /** + * Clean up after a test - ensure thread is continued. + */ + public void function cleanupThread( required numeric threadId ) { + try { + variables.dap.continueThread( arguments.threadId ); + } catch ( any e ) { + // Ignore - thread may already be running + } + waitForHttpComplete(); + } + + /** + * Clear all breakpoints for a file. + */ + public void function clearBreakpoints( required string path ) { + variables.dap.setBreakpoints( arguments.path, [] ); + } + + /** + * Clear all function breakpoints. + */ + public void function clearFunctionBreakpoints() { + variables.dap.setFunctionBreakpoints( [] ); + } + +} diff --git a/test/cfml/DapTestCase.cfm b/test/cfml/DapTestCase.cfm new file mode 100644 index 0000000..793a56c --- /dev/null +++ b/test/cfml/DapTestCase.cfm @@ -0,0 +1,239 @@ + +/** + * Includable DAP test helper functions. + * Include this in tests that extend LuceeTestCase directly. + */ + +variables.dap = javacast( "null", 0 ); +variables.capabilities = {}; +variables.dapHost = ""; +variables.dapPort = 0; +variables.debuggeeHttp = ""; +// Initialize debuggeeArtifactPath at include-time so getArtifactPath() works before setupDap() +variables.debuggeeArtifactPath = server.system.environment.DEBUGGEE_ARTIFACT_PATH ?: ""; +variables.httpThread = ""; +variables.httpResult = {}; + +function setupDap( boolean attach = true, boolean consoleOutput = false ) { + variables.dapHost = server.system.environment.DAP_HOST ?: "localhost"; + variables.dapPort = val( server.system.environment.DAP_PORT ?: 10000 ); + variables.debuggeeHttp = server.system.environment.DEBUGGEE_HTTP ?: "http://localhost:8888"; + variables.debuggeeArtifactPath = server.system.environment.DEBUGGEE_ARTIFACT_PATH ?: ""; + variables.dapSecret = server.system.environment.DAP_SECRET ?: "testing"; + var debug = ( server.system.environment.DAP_DEBUG ?: "false" ) == "true"; + + // Clean up any stale connection from a previous test that may have failed + if ( structKeyExists( server, "_dapTestClient" ) && !isNull( server._dapTestClient ) ) { + systemOutput( "DapTestCase: Cleaning up stale DAP connection", true ); + try { + if ( server._dapTestClient.isConnected() ) { + try { server._dapTestClient.dapDisconnect(); } catch ( any e ) {} + server._dapTestClient.disconnect(); + } + } catch ( any e ) { + // Ignore cleanup errors + } + server._dapTestClient = javacast( "null", 0 ); + } + + systemOutput( "DapTestCase: Connecting to #variables.dapHost#:#variables.dapPort#", true ); + systemOutput( "DapTestCase: Debuggee HTTP at #variables.debuggeeHttp#", true ); + if ( len( variables.debuggeeArtifactPath ) ) { + systemOutput( "DapTestCase: Debuggee artifact path: #variables.debuggeeArtifactPath#", true ); + } + + variables.dap = new DapClient( debug = debug ); + variables.dap.connect( variables.dapHost, variables.dapPort ); + + // Stash in server scope for cleanup by next test if this one fails + server._dapTestClient = variables.dap; + + var initResponse = variables.dap.initialize(); + variables.capabilities = initResponse.body ?: {}; + + systemOutput( "DapTestCase: Capabilities: #serializeJSON( variables.capabilities )#", true ); + + if ( arguments.attach ) { + variables.dap.attach( variables.dapSecret, {}, arguments.consoleOutput ); + variables.dap.configurationDone(); + } +} + +function teardownDap() { + // Optional - can still be called explicitly, but setupDap handles cleanup too + if ( !isNull( variables.dap ) && variables.dap.isConnected() ) { + try { + variables.dap.dapDisconnect(); + } catch ( any e ) { + } + variables.dap.disconnect(); + } + if ( structKeyExists( server, "_dapTestClient" ) ) { + server._dapTestClient = javacast( "null", 0 ); + } +} + +function getCapabilities() { + return variables.capabilities; +} + +function supportsConditionalBreakpoints() { + return variables.capabilities.supportsConditionalBreakpoints ?: false; +} +function notSupportsConditionalBreakpoints() { + return !supportsConditionalBreakpoints(); +} + +function supportsSetVariable() { + return variables.capabilities.supportsSetVariable ?: false; +} +function notSupportsSetVariable() { + return !supportsSetVariable(); +} + +function supportsCompletions() { + return variables.capabilities.supportsCompletionsRequest ?: false; +} +function notSupportsCompletions() { + return !supportsCompletions(); +} + +function supportsFunctionBreakpoints() { + return variables.capabilities.supportsFunctionBreakpoints ?: false; +} +function notSupportsFunctionBreakpoints() { + return !supportsFunctionBreakpoints(); +} + +function supportsBreakpointLocations() { + return variables.capabilities.supportsBreakpointLocationsRequest ?: false; +} +function notSupportsBreakpointLocations() { + return !supportsBreakpointLocations(); +} + +function supportsExceptionInfo() { + return variables.capabilities.supportsExceptionInfoRequest ?: false; +} +function notSupportsExceptionInfo() { + return !supportsExceptionInfo(); +} + +function supportsExceptionBreakpoints() { + var filters = variables.capabilities.exceptionBreakpointFilters ?: []; + return arrayLen( filters ) > 0; +} +function notSupportsExceptionBreakpoints() { + return !supportsExceptionBreakpoints(); +} + +function supportsEvaluate() { + return variables.capabilities.supportsEvaluateForHovers ?: false; +} +function notSupportsEvaluate() { + return !supportsEvaluate(); +} + +// Native mode (Lucee 7.1+) - has breakpointLocations and consoleOutput support +function isNativeMode() { + return variables.capabilities.supportsBreakpointLocationsRequest ?: false; +} +function notNativeMode() { + return !isNativeMode(); +} + +function getArtifactPath( required string filename ) { + if ( len( variables.debuggeeArtifactPath ) ) { + return variables.debuggeeArtifactPath & arguments.filename; + } + var testDir = getDirectoryFromPath( getCurrentTemplatePath() ); + return testDir & "artifacts/" & arguments.filename; +} + +function getArtifactUrl( required string filename ) { + return variables.debuggeeHttp & "/test/cfml/artifacts/" & arguments.filename; +} + +function triggerArtifact( required string filename, struct params = {}, boolean allowErrors = false ) { + var requestUrl = getArtifactUrl( arguments.filename ); + var queryString = ""; + + for ( var key in arguments.params ) { + queryString &= ( len( queryString ) ? "&" : "?" ) & urlEncodedFormat( key ) & "=" & urlEncodedFormat( arguments.params[ key ] ); + } + + requestUrl &= queryString; + variables.httpResult = {}; + variables.httpThread = "httpTrigger_" & createUUID(); + + thread name="#variables.httpThread#" requestUrl=requestUrl httpResult=variables.httpResult allowErrors=arguments.allowErrors { + try { + systemOutput( "triggerArtifact: #attributes.requestUrl#", true ); + http url="#attributes.requestUrl#" result="local.r" timeout=60 throwonerror=!attributes.allowErrors; + + httpResult.status = local.r.statusCode; + httpResult.content = local.r.fileContent; + } catch ( any e ) { + systemOutput( "triggerArtifact HTTP error: #e.message#", true ); + systemOutput( e, true ); + httpResult.error = e.message; + } + } +} + +function waitForHttpComplete( numeric timeout = 30000 ) { + if ( len( variables.httpThread ) ) { + threadJoin( variables.httpThread, arguments.timeout ); + variables.httpThread = ""; + } + return variables.httpResult; +} + +function getTopFrame( required numeric threadId ) { + var stackResponse = variables.dap.stackTrace( arguments.threadId ); + systemOutput( "getTopFrame: threadId=#arguments.threadId# response=#serializeJSON( stackResponse )#", true ); + var frames = stackResponse.body.stackFrames ?: []; + if ( frames.len() == 0 ) { + throw( type="DapTestCase.Error", message="No stack frames available" ); + } + return frames[ 1 ]; +} + +function getScopeByName( required numeric frameId, required string scopeName ) { + var scopesResponse = variables.dap.scopes( arguments.frameId ); + var scopes = scopesResponse.body.scopes ?: []; + for ( var scope in scopes ) { + if ( scope.name == arguments.scopeName ) { + return scope; + } + } + throw( type="DapTestCase.Error", message="Scope not found: #arguments.scopeName#, available scopes: #serializeJSON(scopes)#" ); +} + +function getVariableByName( required numeric variablesReference, required string name ) { + var varsResponse = variables.dap.getVariables( arguments.variablesReference ); + var vars = varsResponse.body.variables ?: []; + for ( var v in vars ) { + if ( v.name == arguments.name ) { + return v; + } + } + throw( type="DapTestCase.Error", message="Variable not found: #arguments.name#, available variables: #serializeJSON(vars)#" ); +} + +function cleanupThread( required numeric threadId ) { + try { + variables.dap.continueThread( arguments.threadId ); + } catch ( any e ) { + } + waitForHttpComplete(); +} + +function clearBreakpoints( required string path ) { + variables.dap.setBreakpoints( arguments.path, [] ); +} + +function clearFunctionBreakpoints() { + variables.dap.setFunctionBreakpoints( [] ); +} + diff --git a/test/cfml/EvaluateTest.cfc b/test/cfml/EvaluateTest.cfc new file mode 100644 index 0000000..5eb7b91 --- /dev/null +++ b/test/cfml/EvaluateTest.cfc @@ -0,0 +1,246 @@ +/** + * Tests for evaluate/expression functionality. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in evaluate-target.cfm - keep in sync with the file + // These are validated in testValidateLineNumbers() using breakpointLocations (native mode only) + variables.lines = { + debugLine: 26 // return localVar & " - " & dataName; + }; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "evaluate-target.cfm" ); + } + + // Validate our line number assumptions using breakpointLocations (native mode only) + function testValidateLineNumbers_evaluateTarget() skip="notSupportsBreakpointLocations" { + var locations = dap.breakpointLocations( variables.targetFile, 1, 40 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location" ); + } + } + + function afterEach() { + clearBreakpoints( variables.targetFile ); + } + + // ========== Basic Evaluate ========== + + function testEvaluateSimpleExpression() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate a simple expression + var evalResponse = dap.evaluate( frame.id, "1 + 1" ); + + expect( evalResponse.body.result ).toBe( "2" ); + + cleanupThread( threadId ); + } + + function testEvaluateLocalVariable() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate local variable + var evalResponse = dap.evaluate( frame.id, "localVar" ); + + expect( evalResponse.body.result ).toBe( '"local-value"' ); + + cleanupThread( threadId ); + } + + function testEvaluateNumericVariable() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate numeric variable + var evalResponse = dap.evaluate( frame.id, "localNum" ); + + expect( evalResponse.body.result ).toBe( "100" ); + + cleanupThread( threadId ); + } + + // ========== Struct/Array Access ========== + + function testEvaluateStructKey() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate struct key access + var evalResponse = dap.evaluate( frame.id, "localStruct.key1" ); + + expect( evalResponse.body.result ).toBe( '"value1"' ); + + cleanupThread( threadId ); + } + + function testEvaluateNestedStructKey() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate nested struct access + var evalResponse = dap.evaluate( frame.id, "localStruct.nested.inner" ); + + expect( evalResponse.body.result ).toBe( '"deep"' ); + + cleanupThread( threadId ); + } + + function testEvaluateArrayElement() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate array element + var evalResponse = dap.evaluate( frame.id, "localArray[2]" ); + + expect( evalResponse.body.result ).toBe( "20" ); + + cleanupThread( threadId ); + } + + // ========== Function Arguments ========== + + function testEvaluateArgumentsScope() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate argument access + var evalResponse = dap.evaluate( frame.id, "arguments.data.name" ); + + expect( evalResponse.body.result ).toBe( '"test-input"' ); + + cleanupThread( threadId ); + } + + // ========== Expressions ========== + + function testEvaluateStringConcatenation() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate string concatenation + var evalResponse = dap.evaluate( frame.id, "localVar & ' - ' & dataName" ); + + expect( evalResponse.body.result ).toBe( '"local-value - test-input"' ); + + cleanupThread( threadId ); + } + + function testEvaluateMathExpression() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate math expression + var evalResponse = dap.evaluate( frame.id, "localNum * 2 + 50" ); + + expect( evalResponse.body.result ).toBe( "250" ); + + cleanupThread( threadId ); + } + + // ========== Built-in Functions ========== + + function testEvaluateBuiltInFunction() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate built-in function + var evalResponse = dap.evaluate( frame.id, "len(localVar)" ); + + expect( evalResponse.body.result ).toBe( "11" ); // "local-value" = 11 chars + + cleanupThread( threadId ); + } + + function testEvaluateArrayLen() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate array length + var evalResponse = dap.evaluate( frame.id, "arrayLen(localArray)" ); + + expect( evalResponse.body.result ).toBe( "3" ); + + cleanupThread( threadId ); + } + +} diff --git a/test/cfml/ExceptionBreakpointsTest.cfc b/test/cfml/ExceptionBreakpointsTest.cfc new file mode 100644 index 0000000..43b8ce9 --- /dev/null +++ b/test/cfml/ExceptionBreakpointsTest.cfc @@ -0,0 +1,188 @@ +/** + * Tests for exception breakpoints (break on uncaught exceptions). + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "exception-target.cfm" ); + } + + function afterEach() { + // Clear exception breakpoints + dap.setExceptionBreakpoints( [] ); + clearBreakpoints( variables.targetFile ); + } + + // ========== Set Exception Breakpoints ========== + + function testSetExceptionBreakpoints() { + var response = dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Response should indicate success + expect( response.success ).toBeTrue(); + } + + function testClearExceptionBreakpoints() { + // Set then clear + dap.setExceptionBreakpoints( [ "uncaught" ] ); + var response = dap.setExceptionBreakpoints( [] ); + + expect( response.success ).toBeTrue(); + } + + // ========== Uncaught Exception Breakpoint ========== + + function testUncaughtExceptionBreakpointHits() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger uncaught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: false }, true ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "exception" ); + + cleanupThread( stopped.body.threadId ); + } + + function testCaughtExceptionDoesNotTriggerUncaughtBreakpoint() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger caught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: true } ); + + // Should NOT stop + sleep( 2000 ); + var hasStoppedEvent = dap.hasEvent( "stopped" ); + expect( hasStoppedEvent ).toBeFalse( "Caught exception should not trigger uncaught breakpoint" ); + + waitForHttpComplete(); + } + + function testNoExceptionDoesNotTriggerBreakpoint() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger without exception + triggerArtifact( "exception-target.cfm", { throwException: false, catchException: false } ); + + // Should NOT stop + sleep( 2000 ); + var hasStoppedEvent = dap.hasEvent( "stopped" ); + expect( hasStoppedEvent ).toBeFalse( "No exception should not trigger breakpoint" ); + + waitForHttpComplete(); + } + + // ========== Exception Info ========== + + function testExceptionInfoReturnsDetails() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger uncaught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: false }, true ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Get exception info + var exceptionInfo = dap.exceptionInfo( threadId ); + + expect( exceptionInfo.body ).toHaveKey( "exceptionId" ); + expect( exceptionInfo.body ).toHaveKey( "description" ); + + // Should contain our test exception message + expect( exceptionInfo.body.description ).toInclude( "Intentional test exception" ); + + cleanupThread( threadId ); + } + + function testExceptionInfoIncludesType() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger uncaught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: false }, true ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Get exception info + var exceptionInfo = dap.exceptionInfo( threadId ); + + // Exception ID should be the type + expect( exceptionInfo.body.exceptionId ).toInclude( "TestException" ); + + cleanupThread( threadId ); + } + + // ========== Stack Trace at Exception ========== + + function testStackTraceAtException() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger uncaught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: false }, true ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Get stack trace + var stackResponse = dap.stackTrace( threadId ); + var frames = stackResponse.body.stackFrames; + + expect( frames.len() ).toBeGTE( 2, "Should have at least 2 stack frames" ); + + // Top frame should be in riskyFunction where exception was thrown + expect( frames[ 1 ].name ).toInclude( "riskyFunction" ); + + cleanupThread( threadId ); + } + + // ========== Variables at Exception ========== + + function testVariablesAtException() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger uncaught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: false }, true ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Should be able to inspect variables even at exception + var frame = getTopFrame( threadId ); + var argsScope = getScopeByName( frame.id, "Arguments" ); + + var shouldThrow = getVariableByName( argsScope.variablesReference, "shouldThrow" ); + expect( shouldThrow.value.lcase() ).toBe( "true" ); + + cleanupThread( threadId ); + } + + // ========== Continue After Exception ========== + + function testContinueAfterException() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger uncaught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: false }, true ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Continue should let the exception propagate + dap.continueThread( threadId ); + + // HTTP request should complete (with error) + var result = waitForHttpComplete(); + + // The request should have failed with an error status or error response + // (Depends on how Lucee handles uncaught exceptions) + expect( result ).toHaveKey( "status" ); + } + +} diff --git a/test/cfml/FunctionBreakpointsTest.cfc b/test/cfml/FunctionBreakpointsTest.cfc new file mode 100644 index 0000000..b2a28f8 --- /dev/null +++ b/test/cfml/FunctionBreakpointsTest.cfc @@ -0,0 +1,190 @@ +/** + * Tests for function breakpoints (break on function name without file/line). + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "function-bp-target.cfm" ); + } + + function afterEach() { + clearFunctionBreakpoints(); + clearBreakpoints( variables.targetFile ); + } + + // ========== Basic Function Breakpoints ========== + + function testSetFunctionBreakpoint() skip="notSupportsFunctionBreakpoints" { + var response = dap.setFunctionBreakpoints( [ "targetFunction" ] ); + + expect( response.body ).toHaveKey( "breakpoints" ); + expect( response.body.breakpoints ).toHaveLength( 1 ); + // Function breakpoints may not be verified until hit + } + + function testFunctionBreakpointHits() skip="notSupportsFunctionBreakpoints" { + dap.setFunctionBreakpoints( [ "targetFunction" ] ); + + triggerArtifact( "function-bp-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "function breakpoint" ); + + // Verify we're in targetFunction + var frame = getTopFrame( stopped.body.threadId ); + expect( frame.name ).toInclude( "targetFunction" ); + + cleanupThread( stopped.body.threadId ); + } + + // ========== Case Insensitivity ========== + + function testFunctionBreakpointCaseInsensitive() skip="notSupportsFunctionBreakpoints" { + // Set breakpoint with different case + dap.setFunctionBreakpoints( [ "TARGETFUNCTION" ] ); + + triggerArtifact( "function-bp-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "function breakpoint" ); + + var frame = getTopFrame( stopped.body.threadId ); + expect( frame.name.lcase() ).toInclude( "targetfunction" ); + + cleanupThread( stopped.body.threadId ); + } + + // ========== Wildcard Function Breakpoints ========== + + function testWildcardFunctionBreakpoint() skip="notSupportsFunctionBreakpoints" { + // Set wildcard breakpoint for onRequest* + dap.setFunctionBreakpoints( [ "onRequest*" ] ); + + triggerArtifact( "function-bp-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "function breakpoint" ); + + // Should hit onRequestStart + var frame = getTopFrame( stopped.body.threadId ); + expect( frame.name.lcase() ).toInclude( "onrequest" ); + + // Continue to see if we hit onRequestEnd too + dap.continueThread( stopped.body.threadId ); + + // May hit onRequestEnd or other onRequest* functions + try { + stopped = dap.waitForEvent( "stopped", 2000 ); + var frame2 = getTopFrame( stopped.body.threadId ); + expect( frame2.name.lcase() ).toInclude( "onrequest" ); + cleanupThread( stopped.body.threadId ); + } catch ( any e ) { + // It's ok if there's no second hit + waitForHttpComplete(); + } + } + + // ========== Multiple Function Breakpoints ========== + + function testMultipleFunctionBreakpoints() skip="notSupportsFunctionBreakpoints" { + dap.setFunctionBreakpoints( [ "targetFunction", "anotherFunction" ] ); + + triggerArtifact( "function-bp-target.cfm" ); + + // Should hit one of the functions + var stopped = dap.waitForEvent( "stopped", 2000 ); + var frame = getTopFrame( stopped.body.threadId ); + + var hitFunction = frame.name.lcase(); + expect( hitFunction contains "targetfunction" || hitFunction contains "anotherfunction" ) + .toBeTrue( "Should hit one of the breakpoint functions" ); + + dap.continueThread( stopped.body.threadId ); + + // Should hit the other function + try { + stopped = dap.waitForEvent( "stopped", 2000 ); + var frame2 = getTopFrame( stopped.body.threadId ); + var hitFunction2 = frame2.name.lcase(); + expect( hitFunction2 contains "targetfunction" || hitFunction2 contains "anotherfunction" ) + .toBeTrue( "Should hit the other breakpoint function" ); + cleanupThread( stopped.body.threadId ); + } catch ( any e ) { + waitForHttpComplete(); + } + } + + // ========== Conditional Function Breakpoints ========== + + function testConditionalFunctionBreakpoint() skip="notSupportsFunctionBreakpoints" { + // Set conditional function breakpoint + dap.setFunctionBreakpoints( [ "targetFunction" ], [ "arguments.input == 'test-value'" ] ); + + triggerArtifact( "function-bp-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "function breakpoint" ); + + // Verify we can access arguments + var frame = getTopFrame( stopped.body.threadId ); + var argsScope = getScopeByName( frame.id, "Arguments" ); + var inputVar = getVariableByName( argsScope.variablesReference, "input" ); + + expect( inputVar.value ).toBe( '"test-value"' ); + + cleanupThread( stopped.body.threadId ); + } + + // ========== Clear Function Breakpoints ========== + + function testClearFunctionBreakpoints() skip="notSupportsFunctionBreakpoints" { + // Set function breakpoint + dap.setFunctionBreakpoints( [ "targetFunction" ] ); + + // Clear it + var response = dap.setFunctionBreakpoints( [] ); + + expect( response.body.breakpoints ).toHaveLength( 0 ); + + // Verify it doesn't hit + triggerArtifact( "function-bp-target.cfm" ); + + sleep( 2000 ); + var hasStoppedEvent = dap.hasEvent( "stopped" ); + expect( hasStoppedEvent ).toBeFalse( "Should not stop after clearing breakpoints" ); + + waitForHttpComplete(); + } + + // ========== Replace Function Breakpoints ========== + + function testReplaceFunctionBreakpoints() skip="notSupportsFunctionBreakpoints" { + // Set initial breakpoint + dap.setFunctionBreakpoints( [ "targetFunction" ] ); + + // Replace with different function + var response = dap.setFunctionBreakpoints( [ "anotherFunction" ] ); + + expect( response.body.breakpoints ).toHaveLength( 1 ); + + triggerArtifact( "function-bp-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var frame = getTopFrame( stopped.body.threadId ); + + // Should hit anotherFunction, not targetFunction + expect( frame.name.lcase() ).toInclude( "anotherfunction" ); + + cleanupThread( stopped.body.threadId ); + } + +} diff --git a/test/cfml/README.md b/test/cfml/README.md new file mode 100644 index 0000000..825fd99 --- /dev/null +++ b/test/cfml/README.md @@ -0,0 +1,77 @@ +# DAP Tests for luceedebug + +TestBox-based tests for the Debug Adapter Protocol functionality. + +## Architecture + +These tests require **two separate Lucee instances**: + +1. **Debuggee** - Lucee with luceedebug enabled + - DAP server on port 10000 + - HTTP server on port 8888 + - Serves the test artifacts + +2. **Test Runner** - Lucee running TestBox tests + - Connects to debuggee via DAP + - Triggers HTTP requests to artifacts + - **Must NOT have luceedebug enabled** (or it would freeze when breakpoints hit) + +## Running Tests Locally + +### Step 1: Start the Debuggee + +Use your existing Lucee dev server with luceedebug, or use Lucee Express: + +1. Download express template from [Lucee Express Templates](https://update.lucee.org/rest/update/provider/expressTemplates) +2. Drop your Lucee JAR in `lib/` +3. Set env vars: `LUCEE_DEBUGGER_SECRET=testing` and `LUCEE_DEBUGGER_PORT=10000` +4. Start Tomcat on port 8888 + +### Step 2: Run the Tests + +```bash +cd test/cfml +test.bat +``` + +Or filter to specific tests: +```bash +set testFilter=BreakpointsTest +test.bat +``` + +## Test Files + +- `DapClient.cfc` - DAP protocol client +- `DapTestCase.cfc` - Base test class with helpers +- `*Test.cfc` - Individual test suites +- `artifacts/*.cfm` - Target files that get debugged + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DAP_HOST` | localhost | Debuggee DAP host | +| `DAP_PORT` | 10000 | Debuggee DAP port | +| `DEBUGGEE_HTTP` | http://localhost:8888 | Debuggee HTTP URL | +| `DAP_DEBUG` | false | Enable DAP client debug logging | + +## Feature Detection + +Tests use capability-based skipping. If a capability isn't supported: +- Native-only features skip on agent mode +- Version-specific features skip on older Lucee + +Example: +```cfml +function testSetVariable() skip="!supportsSetVariable()" { + // Only runs if setVariable is supported +} +``` + +## Adding Tests + +1. Create `YourFeatureTest.cfc` extending `DapTestCase` +2. Add label `labels="dap"` +3. Add target artifact in `artifacts/` if needed +4. Use `skip` attribute for capability-based skipping diff --git a/test/cfml/SetVariableTest.cfc b/test/cfml/SetVariableTest.cfc new file mode 100644 index 0000000..2ce7971 --- /dev/null +++ b/test/cfml/SetVariableTest.cfc @@ -0,0 +1,154 @@ +/** + * Tests for setVariable functionality (modifying variables at runtime). + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in set-variable-target.cfm - keep in sync with the file + // These are validated in testValidateLineNumbers() using breakpointLocations (native mode only) + variables.lines = { + checkpoint1: 14, // var checkpoint1 = true; + checkpoint2: 20 // var checkpoint2 = true; + }; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "set-variable-target.cfm" ); + } + + // Validate our line number assumptions using breakpointLocations (native mode only) + function testValidateLineNumbers_setVariableTarget() skip="notSupportsBreakpointLocations" { + var locations = dap.breakpointLocations( variables.targetFile, 1, 30 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location" ); + } + } + + function afterEach() { + clearBreakpoints( variables.targetFile ); + } + + // ========== Set String Variable ========== + + function testSetStringVariable() skip="notSupportsSetVariable" { + // Set breakpoint at first checkpoint + dap.setBreakpoints( variables.targetFile, [ lines.checkpoint1 ] ); + + triggerArtifact( "set-variable-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + + // Verify original value + var modifiable = getVariableByName( localScope.variablesReference, "modifiable" ); + expect( modifiable.value ).toBe( '"original"' ); + + // Set new value + var setResponse = dap.setVariable( localScope.variablesReference, "modifiable", '"modified"' ); + + expect( setResponse.body.value ).toBe( '"modified"' ); + + // Verify change persisted + var updatedVar = getVariableByName( localScope.variablesReference, "modifiable" ); + expect( updatedVar.value ).toBe( '"modified"' ); + + cleanupThread( threadId ); + } + + // ========== Set Numeric Variable ========== + + function testSetNumericVariable() skip="notSupportsSetVariable" { + dap.setBreakpoints( variables.targetFile, [ lines.checkpoint1 ] ); + + triggerArtifact( "set-variable-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + + // Verify original value + var numericVar = getVariableByName( localScope.variablesReference, "numericVar" ); + expect( numericVar.value ).toBe( "100" ); + + // Set new value + var setResponse = dap.setVariable( localScope.variablesReference, "numericVar", "999" ); + + expect( setResponse.body.value ).toBe( "999" ); + + cleanupThread( threadId ); + } + + // ========== Verify Runtime Effect ========== + + function testSetVariableAffectsExecution() skip="notSupportsSetVariable" { + // Set breakpoints at both checkpoints + dap.setBreakpoints( variables.targetFile, [ lines.checkpoint1, lines.checkpoint2 ] ); + + triggerArtifact( "set-variable-target.cfm" ); + + // Hit first breakpoint + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + + // Modify the variable + dap.setVariable( localScope.variablesReference, "modifiable", '"CHANGED"' ); + dap.setVariable( localScope.variablesReference, "numericVar", "555" ); + + // Continue to second breakpoint + dap.continueThread( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + threadId = stopped.body.threadId; + + // At second checkpoint, verify the result variable reflects our changes + frame = getTopFrame( threadId ); + localScope = getScopeByName( frame.id, "Local" ); + + var result = getVariableByName( localScope.variablesReference, "result" ); + // result = modifiable & " - " & numericVar = "CHANGED - 555" + expect( result.value ).toBe( '"CHANGED - 555"' ); + + cleanupThread( threadId ); + } + + // ========== Set Variable Types ========== + + function testSetBooleanVariable() skip="notSupportsSetVariable" { + dap.setBreakpoints( variables.targetFile, [ lines.checkpoint1 ] ); + + triggerArtifact( "set-variable-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + + // checkpoint1 is a boolean + var checkpoint = getVariableByName( localScope.variablesReference, "checkpoint1" ); + expect( checkpoint.value.lcase() ).toBe( "true" ); + + // Set to false + var setResponse = dap.setVariable( localScope.variablesReference, "checkpoint1", "false" ); + + expect( setResponse.body.value.lcase() ).toBe( "false" ); + + cleanupThread( threadId ); + } + +} diff --git a/test/cfml/SimpleTest.cfc b/test/cfml/SimpleTest.cfc new file mode 100644 index 0000000..4585160 --- /dev/null +++ b/test/cfml/SimpleTest.cfc @@ -0,0 +1,10 @@ +/** + * Simple test to verify test discovery works. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + function testSimple() { + expect( 1 + 1 ).toBe( 2 ); + } + +} diff --git a/test/cfml/SteppingTest.cfc b/test/cfml/SteppingTest.cfc new file mode 100644 index 0000000..d5b940b --- /dev/null +++ b/test/cfml/SteppingTest.cfc @@ -0,0 +1,283 @@ +/** + * Tests for stepping functionality (stepIn, stepOver, stepOut). + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in stepping-target.cfm - keep in sync with the file + // Agent mode (JDWP) stops at function declaration line, native mode stops at first statement + variables.lines = {}; + + function initLines() { + // Base line numbers (native mode - stops at first executable statement) + variables.lines = { + innerFuncDecl: 8, // function innerFunc(...) - agent mode stops here on step-in + innerFuncBody: 9, // var doubled = arguments.x * 2; + innerFuncReturn: 10, // return doubled; + outerFuncDecl: 13, // function outerFunc(...) - agent mode stops here on step-in + outerFuncBody: 14, // var intermediate = arguments.value + 10; + outerFuncCallInner: 15, // var result = innerFunc( intermediate ); + outerFuncReturn: 16, // return result; + mainCallOuter: 21, // finalResult = outerFunc( startValue ); + mainWriteOutput: 23, // writeOutput( "Result: #finalResult#" ); + mainSecondCall: 26, // secondResult = outerFunc( 100 ); + mainSecondOutput: 28 // writeOutput( " Second: #secondResult#" ); + }; + } + + // Get expected line for step-in: agent mode stops at function declaration, native at first statement + function getStepInLine( funcBodyLine, funcDeclLine ) { + if ( isNativeMode() ) { + return funcBodyLine; + } + // Agent mode (JDWP) stops at method entry = function declaration line + return funcDeclLine; + } + + // Get expected line for step-over from a function CALL (not from inside a function) + // Agent mode step-over from a function call stays on same line (JDWP quirk) + // But step-over from inside a function advances normally + function getStepOverFromCallLine( expectedLine, currentLine ) { + if ( isNativeMode() ) { + return expectedLine; + } + // Agent mode step-over from a function call stays on same line + return currentLine; + } + + // Get expected line for step-out: agent mode returns to the call line, native returns to next line + function getStepOutLine( nextLineAfterCall, callLine ) { + if ( isNativeMode() ) { + return nextLineAfterCall; + } + // Agent mode step-out returns to the call line (before it completes) + return callLine; + } + + function run( testResults, testBox ) { + variables.targetFile = getArtifactPath( "stepping-target.cfm" ); + + describe( "Stepping Tests", function() { + + beforeEach( function() { + // Fresh DAP connection for each test - disconnect triggers continueAll() on server + setupDap(); + initLines(); + } ); + + afterEach( function() { + clearBreakpoints( variables.targetFile ); + teardownDap(); + } ); + + it( title="validates line numbers in stepping-target", skip=notSupportsBreakpointLocations(), body=function() { + var locations = dap.breakpointLocations( variables.targetFile, 1, 35 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location. Valid lines: #serializeJSON( validLines )#" ); + } + } ); + + // ========== Step Over ========== + + it( "stepOver skips function call", function() { + // Set breakpoint at call to outerFunc + dap.setBreakpoints( variables.targetFile, [ lines.mainCallOuter ] ); + + triggerArtifact( "stepping-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Verify we're at mainCallOuter + var frame = getTopFrame( threadId ); + expect( frame.line ).toBe( lines.mainCallOuter, "Should start at mainCallOuter" ); + + // Step over - should skip into outerFunc and land on mainWriteOutput + dap.stepOver( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + frame = getTopFrame( threadId ); + var expectedLine = getStepOverFromCallLine( lines.mainWriteOutput, lines.mainCallOuter ); + expect( frame.line ).toBe( expectedLine, "Step over should land on line #expectedLine#" ); + + cleanupThread( threadId ); + } ); + + // ========== Step In ========== + + it( "stepIn enters function", function() { + // Set breakpoint at call to outerFunc + dap.setBreakpoints( variables.targetFile, [ lines.mainCallOuter ] ); + + triggerArtifact( "stepping-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Verify we're at mainCallOuter + var frame = getTopFrame( threadId ); + expect( frame.line ).toBe( lines.mainCallOuter, "Should start at mainCallOuter" ); + + // Step in - should enter outerFunc + dap.stepIn( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + frame = getTopFrame( threadId ); + var expectedLine = getStepInLine( lines.outerFuncBody, lines.outerFuncDecl ); + expect( frame.line ).toBe( expectedLine, "Step in should enter outerFunc at line #expectedLine#" ); + + cleanupThread( threadId ); + } ); + + it( "stepIn enters nested function", function() { + // Set breakpoint at call to outerFunc + dap.setBreakpoints( variables.targetFile, [ lines.mainCallOuter ] ); + + triggerArtifact( "stepping-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Step into outerFunc + dap.stepIn( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + var frame = getTopFrame( threadId ); + var expectedLine = getStepInLine( lines.outerFuncBody, lines.outerFuncDecl ); + expect( frame.line ).toBe( expectedLine, "Should be at outerFunc entry (line #expectedLine#)" ); + + // Step over to advance within outerFunc + // In native mode: from line 14 to 15; in agent mode: from line 13 to 14 + dap.stepOver( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + frame = getTopFrame( threadId ); + // After step-over from entry point, we should be at the first/next statement + // Native: was at 14, now at 15 (outerFuncCallInner) + // Agent: was at 13 (func decl), now at 14 (outerFuncBody) + if ( isNativeMode() ) { + expectedLine = lines.outerFuncCallInner; + } else { + expectedLine = lines.outerFuncBody; + } + expect( frame.line ).toBe( expectedLine, "Should be at line #expectedLine#" ); + + // Agent mode needs an extra step to get to the innerFunc call line + if ( !isNativeMode() ) { + dap.stepOver( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + frame = getTopFrame( threadId ); + expect( frame.line ).toBe( lines.outerFuncCallInner, "Agent mode: should now be at outerFuncCallInner" ); + } + + // Step into innerFunc + dap.stepIn( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + frame = getTopFrame( threadId ); + expectedLine = getStepInLine( lines.innerFuncBody, lines.innerFuncDecl ); + expect( frame.line ).toBe( expectedLine, "Step in should enter innerFunc at line #expectedLine#" ); + + cleanupThread( threadId ); + } ); + + // ========== Step Out ========== + + it( "stepOut exits function", function() { + // Set breakpoint at call to outerFunc + dap.setBreakpoints( variables.targetFile, [ lines.mainCallOuter ] ); + + triggerArtifact( "stepping-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Step into outerFunc + dap.stepIn( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + var frame = getTopFrame( threadId ); + var expectedLine = getStepInLine( lines.outerFuncBody, lines.outerFuncDecl ); + expect( frame.line ).toBe( expectedLine, "Should be inside outerFunc at line #expectedLine#" ); + + // Step out - should return to caller + // Native mode: returns to line after call (mainWriteOutput = 23) + // Agent mode: returns to call line (mainCallOuter = 21) + dap.stepOut( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + frame = getTopFrame( threadId ); + expectedLine = getStepOutLine( lines.mainWriteOutput, lines.mainCallOuter ); + expect( frame.line ).toBe( expectedLine, "Step out should return to line #expectedLine#" ); + + cleanupThread( threadId ); + } ); + + it( "stepOut from nested function returns to caller", function() { + // Set breakpoint inside innerFunc + dap.setBreakpoints( variables.targetFile, [ lines.innerFuncBody ] ); + + triggerArtifact( "stepping-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Verify we're in innerFunc + var frame = getTopFrame( threadId ); + expect( frame.line ).toBe( lines.innerFuncBody, "Should be at innerFuncBody" ); + + // Step out - should return to outerFunc + // Native mode: returns to line after innerFunc call (outerFuncReturn = 16) + // Agent mode: returns to call line (outerFuncCallInner = 15) + dap.stepOut( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + frame = getTopFrame( threadId ); + var expectedLine = getStepOutLine( lines.outerFuncReturn, lines.outerFuncCallInner ); + expect( frame.line ).toBe( expectedLine, "Step out should return to line #expectedLine#" ); + + cleanupThread( threadId ); + } ); + + // ========== Stack Trace ========== + + it( "stack trace shows call hierarchy", function() { + // Set breakpoint inside innerFunc + dap.setBreakpoints( variables.targetFile, [ lines.innerFuncBody ] ); + + triggerArtifact( "stepping-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Get full stack trace + var stackResponse = dap.stackTrace( threadId ); + var frames = stackResponse.body.stackFrames; + + // Should have at least 2 frames: innerFunc -> outerFunc + // Note: Native mode only shows UDF frames, not the top-level "main" frame + // Agent mode may show 3 frames including top-level code + expect( frames.len() ).toBeGTE( 2, "Should have at least 2 stack frames, got #serializeJSON( frames )#" ); + + // Top frame should be innerFunc + expect( frames[ 1 ].name ).toInclude( "innerFunc" ); + expect( frames[ 1 ].line ).toBe( lines.innerFuncBody ); + + // Second frame should be outerFunc + expect( frames[ 2 ].name ).toInclude( "outerFunc" ); + + cleanupThread( threadId ); + } ); + + } ); + } + +} diff --git a/test/cfml/VariablesTest.cfc b/test/cfml/VariablesTest.cfc new file mode 100644 index 0000000..624df3f --- /dev/null +++ b/test/cfml/VariablesTest.cfc @@ -0,0 +1,236 @@ +/** + * Tests for variables and scopes inspection. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in variables-target.cfm - keep in sync with the file + // These are validated in testValidateLineNumbers() using breakpointLocations (native mode only) + variables.lines = { + debugLine: 35 // var debugLine = "inspect here"; + }; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "variables-target.cfm" ); + } + + // Validate our line number assumptions using breakpointLocations (native mode only) + function testValidateLineNumbers_variablesTarget() skip="notSupportsBreakpointLocations" { + var locations = dap.breakpointLocations( variables.targetFile, 1, 45 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location" ); + } + } + + function afterEach() { + clearBreakpoints( variables.targetFile ); + } + + // ========== Scopes ========== + + function testScopesReturnsExpectedScopes() { + // Set breakpoint inside testVariables + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "variables-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var scopesResponse = dap.scopes( frame.id ); + var scopes = scopesResponse.body.scopes; + + // Should have Local, Arguments, and other scopes + var scopeNames = scopes.map( function( s ) { return s.name; } ); + + expect( scopeNames ).toInclude( "Local", "Should have Local scope" ); + expect( scopeNames ).toInclude( "Arguments", "Should have Arguments scope" ); + + cleanupThread( threadId ); + } + + // ========== Local Variables ========== + + function testLocalVariablesShowCorrectTypes() { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "variables-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + var varsResponse = dap.getVariables( localScope.variablesReference ); + var vars = varsResponse.body.variables; + + // Create a lookup map + var varMap = {}; + for ( var v in vars ) { + varMap[ v.name ] = v; + } + + // Check string + expect( varMap ).toHaveKey( "localString" ); + expect( varMap.localString.value ).toBe( '"hello"' ); + + // Check number + expect( varMap ).toHaveKey( "localNumber" ); + expect( varMap.localNumber.value ).toBe( "42" ); + + // Check boolean + expect( varMap ).toHaveKey( "localBoolean" ); + expect( varMap.localBoolean.value.lcase() ).toBe( "true" ); + + // Check array has variablesReference for expansion + expect( varMap ).toHaveKey( "localArray" ); + expect( varMap.localArray.variablesReference ).toBeGT( 0, "Array should be expandable" ); + + // Check struct has variablesReference for expansion + expect( varMap ).toHaveKey( "localStruct" ); + expect( varMap.localStruct.variablesReference ).toBeGT( 0, "Struct should be expandable" ); + + cleanupThread( threadId ); + } + + // ========== Arguments ========== + + function testArgumentsScope() { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "variables-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var argsScope = getScopeByName( frame.id, "Arguments" ); + var varsResponse = dap.getVariables( argsScope.variablesReference ); + var vars = varsResponse.body.variables; + + // Create a lookup map + var varMap = {}; + for ( var v in vars ) { + varMap[ v.name ] = v; + } + + // Check arg1 + expect( varMap ).toHaveKey( "arg1" ); + expect( varMap.arg1.value ).toBe( '"arg-value"' ); + + // Check arg2 + expect( varMap ).toHaveKey( "arg2" ); + expect( varMap.arg2.value ).toBe( "999" ); + + cleanupThread( threadId ); + } + + // ========== Nested Structures ========== + + function testExpandNestedStruct() { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "variables-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + var varsResponse = dap.getVariables( localScope.variablesReference ); + + // Find localStruct + var localStruct = {}; + for ( var v in varsResponse.body.variables ) { + if ( v.name == "localStruct" ) { + localStruct = v; + break; + } + } + + expect( localStruct.variablesReference ).toBeGT( 0, "localStruct should be expandable" ); + + // Expand the struct + var nestedResponse = dap.getVariables( localStruct.variablesReference ); + var nestedVars = nestedResponse.body.variables; + + var nestedMap = {}; + for ( var v in nestedVars ) { + nestedMap[ v.name ] = v; + } + + expect( nestedMap ).toHaveKey( "name" ); + expect( nestedMap ).toHaveKey( "nested" ); + expect( nestedMap.nested.variablesReference ).toBeGT( 0, "nested should be expandable" ); + + // Expand nested.nested + var deepResponse = dap.getVariables( nestedMap.nested.variablesReference ); + var deepVars = deepResponse.body.variables; + + var deepMap = {}; + for ( var v in deepVars ) { + deepMap[ v.name ] = v; + } + + expect( deepMap ).toHaveKey( "deep" ); + expect( deepMap.deep.value ).toBe( '"value"' ); + + cleanupThread( threadId ); + } + + // ========== Arrays ========== + + function testExpandArray() { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "variables-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + var varsResponse = dap.getVariables( localScope.variablesReference ); + + // Find localArray + var localArray = {}; + for ( var v in varsResponse.body.variables ) { + if ( v.name == "localArray" ) { + localArray = v; + break; + } + } + + expect( localArray.variablesReference ).toBeGT( 0, "localArray should be expandable" ); + + // Expand the array + var arrayResponse = dap.getVariables( localArray.variablesReference ); + var arrayVars = arrayResponse.body.variables; + + // Should have indexed elements + expect( arrayVars.len() ).toBeGTE( 5, "Array should have at least 5 elements" ); + + // Check first few elements + var arrayMap = {}; + for ( var v in arrayVars ) { + arrayMap[ v.name ] = v; + } + + expect( arrayMap[ "1" ].value ).toBe( "1" ); + expect( arrayMap[ "2" ].value ).toBe( "2" ); + expect( arrayMap[ "4" ].value ).toBe( '"four"' ); + + cleanupThread( threadId ); + } + +} diff --git a/test/cfml/artifacts/breakpoint-target.cfm b/test/cfml/artifacts/breakpoint-target.cfm new file mode 100644 index 0000000..1076e3e --- /dev/null +++ b/test/cfml/artifacts/breakpoint-target.cfm @@ -0,0 +1,25 @@ + +/** + * Target file for breakpoint tests. + */ + +function simpleFunction( required string name ) { + var greeting = "Hello, " & arguments.name; + return greeting; +} + +function conditionalFunction( required numeric value ) { + var result = 0; + if ( arguments.value > 10 ) { + result = arguments.value * 2; + } else { + result = arguments.value + 5; + } + return result; +} + +output1 = simpleFunction( "Test" ); +output2 = conditionalFunction( 15 ); + +writeOutput( "Done: #output1# / #output2#" ); + diff --git a/test/cfml/artifacts/completions-target.cfm b/test/cfml/artifacts/completions-target.cfm new file mode 100644 index 0000000..2212d7c --- /dev/null +++ b/test/cfml/artifacts/completions-target.cfm @@ -0,0 +1,30 @@ + +/** + * Target file for completions/autocomplete tests. + * + * Tests debug console autocomplete functionality. + */ + +function testCompletions() { + var myString = "hello"; + var myNumber = 42; + var myStruct = { + "firstName": "John", + "lastName": "Doe", + "address": { + "street": "123 Main St", + "city": "Springfield" + } + }; + var myArray = [ 1, 2, 3 ]; + var myQuery = queryNew( "id,name", "integer,varchar", [ { id: 1, name: "test" } ] ); + + var stopHere = true; + + return myString; +} + +result = testCompletions(); + +writeOutput( "Done: #result#" ); + diff --git a/test/cfml/artifacts/console-output-target.cfm b/test/cfml/artifacts/console-output-target.cfm new file mode 100644 index 0000000..8f73a1a --- /dev/null +++ b/test/cfml/artifacts/console-output-target.cfm @@ -0,0 +1,22 @@ + +/** + * Target file for console output tests. + * + * Tests that systemOutput() streams to debug console via DAP output events. + */ + +function testOutput( required string message ) { + var prefix = "ConsoleOutputTest"; + + // Output to stdout + systemOutput( "#prefix#: #arguments.message#", true ); + + var stopHere = true; + + return arguments.message; +} + +result = testOutput( url.message ?: "default-message" ); + +writeOutput( "Done: #result#" ); + diff --git a/test/cfml/artifacts/debug-threads.cfm b/test/cfml/artifacts/debug-threads.cfm new file mode 100644 index 0000000..62d2a4e --- /dev/null +++ b/test/cfml/artifacts/debug-threads.cfm @@ -0,0 +1,27 @@ + +// Dump luceedebug threads for debugging DAP server issues +threads = createObject( "java", "java.lang.management.ManagementFactory" ) + .getThreadMXBean() + .dumpAllThreads( true, true ); + +found = false; +for ( t in threads ) { + if ( t.getThreadName() contains "luceedebug" ) { + found = true; + echo( "Thread: #t.getThreadName()#" & chr(10) ); + echo( "State: #t.getThreadState().name()#" & chr(10) ); + for ( f in t.getStackTrace() ) { + echo( " at #f.toString()#" & chr(10) ); + } + echo( chr(10) ); + } +} + +if ( !found ) { + echo( "No luceedebug threads found!" & chr(10) ); + echo( chr(10) & "All threads:" & chr(10) ); + for ( t in threads ) { + echo( "#t.getThreadName()# - #t.getThreadState().name()#" & chr(10) ); + } +} + diff --git a/test/cfml/artifacts/evaluate-target.cfm b/test/cfml/artifacts/evaluate-target.cfm new file mode 100644 index 0000000..8573c5a --- /dev/null +++ b/test/cfml/artifacts/evaluate-target.cfm @@ -0,0 +1,37 @@ + +/** + * Target file for evaluate/expression tests. + * + * Tests evaluate functionality in watch/repl context. + */ + +function testEvaluate( required struct data ) { + var localVar = "local-value"; + var localNum = 100; + var localArray = [ 10, 20, 30 ]; + var localStruct = { + "key1": "value1", + "key2": 42, + "nested": { + "inner": "deep" + } + }; + + // Access argument + var dataName = arguments.data.name; + var dataValue = arguments.data.value; + + var stopHere = true; + + return localVar & " - " & dataName; +} + +inputData = { + "name": "test-input", + "value": 12345 +}; + +result = testEvaluate( inputData ); + +writeOutput( "Result: #result#" ); + diff --git a/test/cfml/artifacts/exception-target.cfm b/test/cfml/artifacts/exception-target.cfm new file mode 100644 index 0000000..01c7f3d --- /dev/null +++ b/test/cfml/artifacts/exception-target.cfm @@ -0,0 +1,36 @@ + +/** + * Target file for exception breakpoint tests. + * + * Tests breaking on uncaught exceptions. + */ + +param name="url.throwException" default="false"; +param name="url.catchException" default="false"; + +function riskyFunction( required boolean shouldThrow ) { + if ( arguments.shouldThrow ) { + throw( type="TestException", message="Intentional test exception", detail="This is for testing" ); + } + return "success"; +} + +function wrapperFunction( required boolean shouldThrow, required boolean shouldCatch ) { + if ( arguments.shouldCatch ) { + try { + return riskyFunction( arguments.shouldThrow ); + } catch ( any e ) { + return "caught: " & e.message; + } + } else { + return riskyFunction( arguments.shouldThrow ); + } +} + +result = wrapperFunction( + shouldThrow = url.throwException == "true", + shouldCatch = url.catchException == "true" +); + +writeOutput( "Result: #result#" ); + diff --git a/test/cfml/artifacts/function-bp-target.cfm b/test/cfml/artifacts/function-bp-target.cfm new file mode 100644 index 0000000..f24dda6 --- /dev/null +++ b/test/cfml/artifacts/function-bp-target.cfm @@ -0,0 +1,40 @@ + +/** + * Target file for function breakpoint tests. + * + * Tests breaking on function names without specifying file/line. + * + * Function breakpoint targets: + * - targetFunction (exact match) + * - onRequest* (wildcard) + */ + +function targetFunction( required string input ) { + var result = "processed: " & arguments.input; + return result; +} + +function onRequestStart() { + // Simulates Application.cfc callback + return true; +} + +function onRequestEnd() { + // Simulates Application.cfc callback + return true; +} + +function anotherFunction() { + return "another"; +} + +// Execution +onRequestStart(); + +result1 = targetFunction( "test-value" ); +result2 = anotherFunction(); + +onRequestEnd(); + +writeOutput( "Results: #result1# / #result2#" ); + diff --git a/test/cfml/artifacts/set-variable-target.cfm b/test/cfml/artifacts/set-variable-target.cfm new file mode 100644 index 0000000..5699fe6 --- /dev/null +++ b/test/cfml/artifacts/set-variable-target.cfm @@ -0,0 +1,28 @@ + +/** + * Target file for setVariable tests. + * + * Tests modifying variables at runtime. + */ + +function testSetVariable( required string input ) { + var modifiable = "original"; + var numericVar = 100; + var structVar = { "key": "original-value" }; + + // First breakpoint - modify variables here + var checkpoint1 = true; + + // Use the (potentially modified) values + var result = modifiable & " - " & numericVar; + + // Second checkpoint after potential modification + var checkpoint2 = true; + + return result; +} + +output = testSetVariable( "test-input" ); + +writeOutput( "Output: #output#" ); + diff --git a/test/cfml/artifacts/stepping-target.cfm b/test/cfml/artifacts/stepping-target.cfm new file mode 100644 index 0000000..72f2edd --- /dev/null +++ b/test/cfml/artifacts/stepping-target.cfm @@ -0,0 +1,29 @@ + +/** + * Target file for stepping tests (stepIn, stepOver, stepOut). + * + * Call hierarchy: main -> outerFunc -> innerFunc + */ + +function innerFunc( required numeric x ) { + var doubled = arguments.x * 2; + return doubled; +} + +function outerFunc( required numeric value ) { + var intermediate = arguments.value + 10; + var result = innerFunc( intermediate ); + return result; +} + +// Main execution +startValue = 5; +finalResult = outerFunc( startValue ); + +writeOutput( "Result: #finalResult#" ); + +// Second call for additional stepping tests +secondResult = outerFunc( 100 ); + +writeOutput( " Second: #secondResult#" ); + diff --git a/test/cfml/artifacts/variables-target.cfm b/test/cfml/artifacts/variables-target.cfm new file mode 100644 index 0000000..7173d25 --- /dev/null +++ b/test/cfml/artifacts/variables-target.cfm @@ -0,0 +1,43 @@ + +/** + * Target file for variables and scopes tests. + * + * Tests various data types and scope access. + */ + +// Set up test data in various scopes +url.urlVar = "from-url"; +form.formVar = "from-form"; +request.requestVar = "from-request"; + +function testVariables( required string arg1, required numeric arg2 ) { + // Local variables of different types + var localString = "hello"; + var localNumber = 42; + var localFloat = 3.14159; + var localBoolean = true; + var localArray = [ 1, 2, 3, "four", { nested: true } ]; + var localStruct = { + "name": "test", + "value": 123, + "nested": { + "deep": "value" + } + }; + var localDate = now(); + var localNull = javacast( "null", 0 ); + + // Access various scopes + var fromUrl = url.urlVar; + var fromForm = form.formVar; + var fromRequest = request.requestVar; + + var debugLine = "inspect here"; + + return localString & " " & localNumber; +} + +result = testVariables( "arg-value", 999 ); + +writeOutput( "Done: #result#" ); + diff --git a/test/cfml/test.bat b/test/cfml/test.bat new file mode 100644 index 0000000..87ad096 --- /dev/null +++ b/test/cfml/test.bat @@ -0,0 +1,8 @@ +cls +SET JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-21.0.5.11-hotspot +set testLabels=dap +set testFilter= +set LUCEE_LOGGING_FORCE_APPENDER=console +set LUCEE_LOGGING_FORCE_LEVEL=info + +ant -buildfile="D:/work/script-runner/build.xml" -Dwebroot="D:/work/lucee7/test" -Dexecute="bootstrap-tests.cfm" -DluceeVersionQuery="7.0/all/light" -DtestAdditional="D:\work\lucee-extensions\luceedebug\test\cfml" -DtestLabels="%testLabels%" -DtestFilter="%testFilter%" -DtestDebug="false" From d5561e34003b4fb085a660c1751f40233023457d Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 8 Dec 2025 20:23:12 +0100 Subject: [PATCH 14/14] LDEV-1402 switch env var prefix to LUCEE_DAP_ ( was LUCEE_DEBUGGER_ ) --- .github/workflows/test-dap.yml | 17 +- extension/META-INF/MANIFEST.MF | 4 +- luceedebug/build.gradle.kts | 4 +- .../src/main/java/luceedebug/DapServer.java | 7 +- .../src/main/java/luceedebug/EnvUtil.java | 32 +- .../coreinject/frame/NativeDebugFrame.java | 4 +- .../extension/ExtensionActivator.java | 2 +- profiling/DapClient.cfc | 339 ------------------ profiling/NATIVE_DEBUGGING_TESTING.md | 178 --------- profiling/README.md | 223 ------------ profiling/benchmark.cfm | 115 ------ profiling/compare-results.bat | 55 --- profiling/dap-step-target.cfm | 24 -- profiling/dap-target.cfm | 14 - profiling/profile-baseline-docs.bat | 34 -- profiling/profile-baseline-spreadsheet.bat | 31 -- profiling/profile-baseline.bat | 31 -- profiling/profile-local-lucee.bat | 52 --- profiling/profile-with-agent-docs.bat | 46 --- profiling/profile-with-agent-spreadsheet.bat | 43 --- profiling/profile-with-agent.bat | 43 --- profiling/test-breakpoint-bif.bat | 25 -- profiling/test-breakpoint-bif.cfm | 56 --- profiling/test-dap-breakpoint.cfm | 145 -------- profiling/test-dap-stepping.bat | 47 --- profiling/test-dap-stepping.cfm | 238 ------------ profiling/test-debugger-frames.cfm | 68 ---- profiling/test-extension.bat | 46 --- profiling/test-extension.cfm | 78 ---- profiling/test-native-frames-with-agent.bat | 50 --- profiling/test-native-frames.bat | 32 -- profiling/test-threads.bat | 30 -- profiling/test-threads.cfm | 40 --- test/cfml/README.md | 2 +- vscode-client/package.json | 2 +- 35 files changed, 47 insertions(+), 2110 deletions(-) delete mode 100644 profiling/DapClient.cfc delete mode 100644 profiling/NATIVE_DEBUGGING_TESTING.md delete mode 100644 profiling/README.md delete mode 100644 profiling/benchmark.cfm delete mode 100644 profiling/compare-results.bat delete mode 100644 profiling/dap-step-target.cfm delete mode 100644 profiling/dap-target.cfm delete mode 100644 profiling/profile-baseline-docs.bat delete mode 100644 profiling/profile-baseline-spreadsheet.bat delete mode 100644 profiling/profile-baseline.bat delete mode 100644 profiling/profile-local-lucee.bat delete mode 100644 profiling/profile-with-agent-docs.bat delete mode 100644 profiling/profile-with-agent-spreadsheet.bat delete mode 100644 profiling/profile-with-agent.bat delete mode 100644 profiling/test-breakpoint-bif.bat delete mode 100644 profiling/test-breakpoint-bif.cfm delete mode 100644 profiling/test-dap-breakpoint.cfm delete mode 100644 profiling/test-dap-stepping.bat delete mode 100644 profiling/test-dap-stepping.cfm delete mode 100644 profiling/test-debugger-frames.cfm delete mode 100644 profiling/test-extension.bat delete mode 100644 profiling/test-extension.cfm delete mode 100644 profiling/test-native-frames-with-agent.bat delete mode 100644 profiling/test-native-frames.bat delete mode 100644 profiling/test-threads.bat delete mode 100644 profiling/test-threads.cfm diff --git a/.github/workflows/test-dap.yml b/.github/workflows/test-dap.yml index 3c41fc7..4e0099b 100644 --- a/.github/workflows/test-dap.yml +++ b/.github/workflows/test-dap.yml @@ -190,13 +190,13 @@ jobs: - name: Configure debuggee setenv.sh run: | - echo 'export LUCEE_DEBUGGER_SECRET=testing' >> debuggee/bin/setenv.sh - echo 'export LUCEE_DEBUGGER_PORT=10000' >> debuggee/bin/setenv.sh + echo 'export LUCEE_DAP_SECRET=testing' >> debuggee/bin/setenv.sh + echo 'export LUCEE_DAP_PORT=10000' >> debuggee/bin/setenv.sh echo 'export LUCEE_LOGGING_FORCE_LEVEL=trace' >> debuggee/bin/setenv.sh # Enable Felix OSGi debug logging to diagnose bundle unload echo 'export FELIX_LOG_LEVEL=debug' >> debuggee/bin/setenv.sh # Enable luceedebug internal debug logging - echo 'export LUCEE_DEBUGGER_DEBUG=true' >> debuggee/bin/setenv.sh + echo 'export LUCEE_DAP_DEBUG=true' >> debuggee/bin/setenv.sh chmod +x debuggee/bin/setenv.sh - name: Warmup debuggee (Lucee Express) @@ -375,15 +375,22 @@ jobs: echo "AGENT_JAR=$AGENT_JAR" >> $GITHUB_ENV cp $AGENT_JAR debuggee/ + - name: Upload agent JAR + uses: actions/upload-artifact@v4 + with: + name: luceedebug-agent + path: luceedebug/build/libs/luceedebug-*.jar + retention-days: 1 + - name: Configure debuggee for agent mode run: | AGENT_JAR_NAME=$(basename $AGENT_JAR) # Add JVM args for JDWP and luceedebug agent # Secret is read from env var at connection time, not javaagent args - echo "export LUCEE_DEBUGGER_SECRET=testing" >> debuggee/bin/setenv.sh + echo "export LUCEE_DAP_SECRET=testing" >> debuggee/bin/setenv.sh echo "export LUCEE_LOGGING_FORCE_LEVEL=trace" >> debuggee/bin/setenv.sh # Enable luceedebug internal debug logging - echo "export LUCEE_DEBUGGER_DEBUG=true" >> debuggee/bin/setenv.sh + echo "export LUCEE_DAP_DEBUG=true" >> debuggee/bin/setenv.sh echo "export CATALINA_OPTS=\"\$CATALINA_OPTS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=localhost:9999\"" >> debuggee/bin/setenv.sh echo "export CATALINA_OPTS=\"\$CATALINA_OPTS -javaagent:\$CATALINA_HOME/$AGENT_JAR_NAME=jdwpHost=localhost,jdwpPort=9999,debugHost=0.0.0.0,debugPort=10000,jarPath=\$CATALINA_HOME/$AGENT_JAR_NAME\"" >> debuggee/bin/setenv.sh chmod +x debuggee/bin/setenv.sh diff --git a/extension/META-INF/MANIFEST.MF b/extension/META-INF/MANIFEST.MF index f5a98d4..c9994c2 100644 --- a/extension/META-INF/MANIFEST.MF +++ b/extension/META-INF/MANIFEST.MF @@ -3,8 +3,8 @@ id: FA79A831-7D30-4D8A-B7F300DECEB00001 name: "Luceedebug" symbolic-name: "luceedebug" description: "Native CFML debugger for VS Code - no Java agent required" -version: "3.0.0" +version: "3.0.0-BETA" lucee-core-version: "7.1.0.6" start-bundles: true release-type: server -startup-hook: [{"class": "luceedebug.extension.ExtensionActivator", "bundle-name": "luceedebug-osgi", "bundle-version": "3.0.0.0"}] +startup-hook: [{"class": "luceedebug.extension.ExtensionActivator", "bundle-name": "luceedebug-osgi", "bundle-version": "3.0.0.0-BETA"}] diff --git a/luceedebug/build.gradle.kts b/luceedebug/build.gradle.kts index 8527218..a73ea69 100644 --- a/luceedebug/build.gradle.kts +++ b/luceedebug/build.gradle.kts @@ -89,14 +89,14 @@ tasks.jar { "Premain-Class" to "luceedebug.Agent", "Can-Redefine-Classes" to "true", "Bundle-SymbolicName" to "luceedebug-osgi", - "Bundle-Version" to "3.0.0.0", + "Bundle-Version" to "3.0.0.0-BETA", "Export-Package" to "luceedebug.*" ) ) } } -val luceedebugVersion = "3.0.0" +val luceedebugVersion = "3.0.0-BETA" val libfile = "luceedebug-" + luceedebugVersion + ".jar" // TODO: this should, but does not currently, participate in the `clean` task, so the generated file sticks around after invoking `clean`. diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index ffa35d9..de37e14 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -354,9 +354,10 @@ public CompletableFuture initialize(InitializeRequestArguments arg c.setSupportsLogPoints(false); // still shows UI for it though // Native-mode-only capabilities (require Lucee 7.1+ DebuggerRegistry) - boolean isNativeMode = luceeVm_ instanceof NativeLuceeVm; + // Also check if debugger is actually enabled (LUCEE_DAP_BREAKPOINT not set to false) + boolean isNativeMode = luceeVm_ instanceof NativeLuceeVm && EnvUtil.isDebuggerEnabled(); - // Exception breakpoint filters - only supported in native mode + // Exception breakpoint filters - only supported in native mode with debugger enabled if (isNativeMode) { var uncaughtFilter = new ExceptionBreakpointsFilter(); uncaughtFilter.setFilter("uncaught"); @@ -529,7 +530,7 @@ private boolean validateSecret(Map args) { if (expectedSecret == null) { // No secret configured on server - allow any secret for backwards compatibility? // No - require secret to be set for security - Log.error("LUCEE_DEBUGGER_SECRET not set on server"); + Log.error("LUCEE_DAP_SECRET not set on server"); return false; } diff --git a/luceedebug/src/main/java/luceedebug/EnvUtil.java b/luceedebug/src/main/java/luceedebug/EnvUtil.java index 22d5a19..63cc77d 100644 --- a/luceedebug/src/main/java/luceedebug/EnvUtil.java +++ b/luceedebug/src/main/java/luceedebug/EnvUtil.java @@ -22,19 +22,19 @@ public static String getSystemPropOrEnvVar(String propertyName) { if (value != null && !value.isEmpty()) { return value; } - // Try env var (lucee.debugger.port -> LUCEE_DEBUGGER_PORT) + // Try env var (lucee.dap.port -> LUCEE_DAP_PORT) String envName = propertyName.toUpperCase().replace('.', '_'); return System.getenv(envName); } /** - * Get debugger secret from environment/system property. - * Checks "lucee.debugger.secret" / "LUCEE_DEBUGGER_SECRET". + * Get DAP secret from environment/system property. + * Checks "lucee.dap.secret" / "LUCEE_DAP_SECRET". * * @return the secret, or null if not set (debugger disabled) */ public static String getDebuggerSecret() { - String secret = getSystemPropOrEnvVar("lucee.debugger.secret"); + String secret = getSystemPropOrEnvVar("lucee.dap.secret"); if (secret != null && !secret.trim().isEmpty()) { return secret.trim(); } @@ -42,17 +42,29 @@ public static String getDebuggerSecret() { } /** - * Check if debugger is enabled (secret is set). + * Check if DAP breakpoint support is enabled. + * Reads ConfigImpl.DEBUGGER static field via reflection to match Lucee's state. * - * @return true if debugger secret is configured + * @return true if DAP breakpoint support is enabled */ public static boolean isDebuggerEnabled() { - return getDebuggerSecret() != null; + try { + Class configImpl = Class.forName("lucee.runtime.config.ConfigImpl"); + java.lang.reflect.Field field = configImpl.getField("DEBUGGER"); + return (boolean) field.get(null); + } catch (Exception e) { + // Fallback to env var check if reflection fails (e.g. older Lucee) + if (getDebuggerSecret() == null) { + return false; + } + String bp = getSystemPropOrEnvVar("lucee.dap.breakpoint"); + return bp == null || "true".equalsIgnoreCase(bp.trim()); + } } /** - * Get debugger port from environment/system property. - * Checks "lucee.debugger.port" / "LUCEE_DEBUGGER_PORT". + * Get DAP port from environment/system property. + * Checks "lucee.dap.port" / "LUCEE_DAP_PORT". * Defaults to 9999 if secret is set but port is not. * * @return the port number, or -1 if debugger disabled (no secret) @@ -62,7 +74,7 @@ public static int getDebuggerPort() { if (getDebuggerSecret() == null) { return -1; } - String port = getSystemPropOrEnvVar("lucee.debugger.port"); + String port = getSystemPropOrEnvVar("lucee.dap.port"); if (port == null || port.isEmpty()) { return 9999; // default port } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java index cb4e94c..f3c623b 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java @@ -355,9 +355,9 @@ private static synchronized boolean initReflection( ClassLoader luceeClassLoader } try { - // Check if debugger is enabled (via LUCEE_DEBUGGER_SECRET env var) + // Check if DAP debugger is enabled (via LUCEE_DAP_SECRET env var) if ( !EnvUtil.isDebuggerEnabled() ) { - Log.info( "Native frame support disabled: LUCEE_DEBUGGER_SECRET not set" ); + Log.info( "Native frame support disabled: LUCEE_DAP_SECRET not set" ); nativeFrameSupportAvailable = false; return false; } diff --git a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java index bea7182..0a78449 100644 --- a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java +++ b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java @@ -44,7 +44,7 @@ public ExtensionActivator(Config luceeConfig) { // Get debug port - if not set, debugger is disabled int debugPort = EnvUtil.getDebuggerPort(); if (debugPort < 0) { - Log.info("Debugger disabled - set LUCEE_DEBUGGER_SECRET to enable"); + Log.info("Debugger disabled - set LUCEE_DAP_SECRET to enable"); return; } Log.info("Extension activating"); diff --git a/profiling/DapClient.cfc b/profiling/DapClient.cfc deleted file mode 100644 index d80f719..0000000 --- a/profiling/DapClient.cfc +++ /dev/null @@ -1,339 +0,0 @@ -/** - * DAP (Debug Adapter Protocol) client for testing luceedebug. - * - * Usage: - * dap = new DapClient(); - * dap.connect( "localhost", 10000 ); - * dap.initialize(); - * dap.setBreakpoints( "/path/to/file.cfm", [ 10, 20 ] ); - * dap.configurationDone(); - * // ... trigger breakpoint via HTTP ... - * event = dap.waitForEvent( "stopped", 5000 ); - * stack = dap.stackTrace( event.body.threadId ); - * dap.continueThread( event.body.threadId ); - * dap.disconnect(); - */ -component { - - variables.socket = javacast( "null", 0 ); - variables.inputStream = javacast( "null", 0 ); - variables.outputStream = javacast( "null", 0 ); - variables.seq = 0; - variables.eventQueue = []; - variables.pendingResponses = {}; - variables.debug = false; - - public function init( boolean debug = false ) { - variables.debug = arguments.debug; - return this; - } - - // ========== Connection ========== - - public function connect( required string host, required numeric port ) { - variables.socket = createObject( "java", "java.net.Socket" ).init( arguments.host, arguments.port ); - variables.socket.setSoTimeout( 100 ); // 100ms read timeout for polling - variables.inputStream = variables.socket.getInputStream(); - variables.outputStream = variables.socket.getOutputStream(); - debugLog( "Connected to #arguments.host#:#arguments.port#" ); - } - - public function disconnect() { - if ( !isNull( variables.socket ) ) { - variables.socket.close(); - variables.socket = javacast( "null", 0 ); - debugLog( "Disconnected" ); - } - } - - public boolean function isConnected() { - return !isNull( variables.socket ) && variables.socket.isConnected() && !variables.socket.isClosed(); - } - - // ========== DAP Commands ========== - - public struct function initialize() { - var response = sendRequest( "initialize", { - "clientID": "cfml-dap-test", - "adapterID": "luceedebug", - "pathFormat": "path", - "linesStartAt1": true, - "columnsStartAt1": true - } ); - return response; - } - - public struct function setBreakpoints( required string path, required array lines ) { - var breakpoints = []; - for ( var line in arguments.lines ) { - breakpoints.append( { "line": line } ); - } - var response = sendRequest( "setBreakpoints", { - "source": { "path": arguments.path }, - "breakpoints": breakpoints - } ); - return response; - } - - public struct function configurationDone() { - return sendRequest( "configurationDone", {} ); - } - - public struct function threads() { - return sendRequest( "threads", {} ); - } - - public struct function stackTrace( required numeric threadId, numeric startFrame = 0, numeric levels = 20 ) { - return sendRequest( "stackTrace", { - "threadId": arguments.threadId, - "startFrame": arguments.startFrame, - "levels": arguments.levels - } ); - } - - public struct function scopes( required numeric frameId ) { - return sendRequest( "scopes", { - "frameId": arguments.frameId - } ); - } - - public struct function getVariables( required numeric variablesReference ) { - return sendRequest( "variables", { - "variablesReference": arguments.variablesReference - } ); - } - - public struct function continueThread( required numeric threadId ) { - return sendRequest( "continue", { - "threadId": arguments.threadId - } ); - } - - public struct function stepOver( required numeric threadId ) { - return sendRequest( "next", { - "threadId": arguments.threadId - } ); - } - - public struct function stepIn( required numeric threadId ) { - return sendRequest( "stepIn", { - "threadId": arguments.threadId - } ); - } - - public struct function stepOut( required numeric threadId ) { - return sendRequest( "stepOut", { - "threadId": arguments.threadId - } ); - } - - public struct function evaluate( required numeric frameId, required string expression ) { - return sendRequest( "evaluate", { - "frameId": arguments.frameId, - "expression": arguments.expression, - "context": "watch" - } ); - } - - public struct function dapDisconnect() { - return sendRequest( "disconnect", {} ); - } - - // ========== Event Handling ========== - - /** - * Wait for a specific event type. - * @eventType The event type to wait for (e.g., "stopped", "thread") - * @timeoutMs Maximum time to wait in milliseconds - * @return The event struct, or throws if timeout - */ - public struct function waitForEvent( required string eventType, numeric timeoutMs = 5000 ) { - var startTime = getTickCount(); - - while ( getTickCount() - startTime < arguments.timeoutMs ) { - // Check queued events first - for ( var i = 1; i <= variables.eventQueue.len(); i++ ) { - if ( variables.eventQueue[ i ].event == arguments.eventType ) { - var event = variables.eventQueue[ i ]; - variables.eventQueue.deleteAt( i ); - return event; - } - } - - // Poll for new messages - pollMessages(); - sleep( 10 ); - } - - throw( type="DapClient.Timeout", message="Timeout waiting for event: #arguments.eventType#" ); - } - - /** - * Check if any events of a type are queued. - */ - public boolean function hasEvent( required string eventType ) { - pollMessages(); - for ( var event in variables.eventQueue ) { - if ( event.event == arguments.eventType ) { - return true; - } - } - return false; - } - - /** - * Get all queued events (clears the queue). - */ - public array function drainEvents() { - pollMessages(); - var events = variables.eventQueue; - variables.eventQueue = []; - return events; - } - - // ========== Protocol Implementation ========== - - private struct function sendRequest( required string command, required struct args ) { - var requestSeq = ++variables.seq; - var dapRequest = { - "seq": requestSeq, - "type": "request", - "command": arguments.command, - "arguments": arguments.args - }; - - sendMessage( dapRequest ); - - // Wait for response with matching request_seq - var startTime = getTickCount(); - var timeout = 10000; // 10 second timeout for responses - - while ( getTickCount() - startTime < timeout ) { - pollMessages(); - - if ( variables.pendingResponses.keyExists( requestSeq ) ) { - var response = variables.pendingResponses[ requestSeq ]; - variables.pendingResponses.delete( requestSeq ); - - if ( !response.success ) { - throw( type="DapClient.Error", message="DAP error: #response.message ?: 'unknown'#" ); - } - - return response; - } - - sleep( 10 ); - } - - throw( type="DapClient.Timeout", message="Timeout waiting for response to: #arguments.command#" ); - } - - private void function sendMessage( required struct message ) { - var json = serializeJSON( arguments.message ); - var bytes = json.getBytes( "UTF-8" ); - var CRLF = chr( 13 ) & chr( 10 ); - var header = "Content-Length: #arrayLen( bytes )#" & CRLF & CRLF; - - debugLog( ">>> #json#" ); - - variables.outputStream.write( header.getBytes( "UTF-8" ) ); - variables.outputStream.write( bytes ); - variables.outputStream.flush(); - } - - private void function pollMessages() { - try { - while ( variables.inputStream.available() > 0 ) { - var message = readMessage(); - if ( !isNull( message ) ) { - handleMessage( message ); - } - } - } catch ( any e ) { - // Socket timeout is expected, ignore - if ( !e.message contains "timed out" && !e.message contains "Read timed out" ) { - rethrow; - } - } - } - - private any function readMessage() { - // Read headers until empty line - var headers = {}; - var headerLine = readLine(); - - while ( headerLine != "" ) { - var colonPos = headerLine.find( ":" ); - if ( colonPos > 0 ) { - var key = headerLine.left( colonPos - 1 ).trim(); - var value = headerLine.mid( colonPos + 1, headerLine.len() ).trim(); - headers[ key ] = value; - } - headerLine = readLine(); - } - - if ( !headers.keyExists( "Content-Length" ) ) { - return javacast( "null", 0 ); - } - - // Read body - var contentLength = val( headers[ "Content-Length" ] ); - var bodyBytes = createObject( "java", "java.io.ByteArrayOutputStream" ).init(); - var remaining = contentLength; - - while ( remaining > 0 ) { - var b = variables.inputStream.read(); - if ( b == -1 ) { - throw( type="DapClient.Error", message="Unexpected end of stream" ); - } - bodyBytes.write( b ); - remaining--; - } - - var json = bodyBytes.toString( "UTF-8" ); - debugLog( "<<< #json#" ); - - return deserializeJSON( json ); - } - - private string function readLine() { - var line = createObject( "java", "java.lang.StringBuilder" ).init(); - var prevChar = 0; - - while ( true ) { - var b = variables.inputStream.read(); - if ( b == -1 ) { - break; - } - var c = chr( b ); - if ( c == chr( 10 ) ) { // LF - break; - } - if ( c != chr( 13 ) ) { // Skip CR - line.append( c ); - } - } - - return line.toString(); - } - - private void function handleMessage( required struct message ) { - switch ( arguments.message.type ) { - case "response": - variables.pendingResponses[ arguments.message.request_seq ] = arguments.message; - break; - case "event": - variables.eventQueue.append( arguments.message ); - break; - default: - debugLog( "Unknown message type: #arguments.message.type#" ); - } - } - - private void function debugLog( required string msg ) { - if ( variables.debug ) { - systemOutput( "[DapClient] #arguments.msg#", true ); - } - } - -} diff --git a/profiling/NATIVE_DEBUGGING_TESTING.md b/profiling/NATIVE_DEBUGGING_TESTING.md deleted file mode 100644 index 8b0eca9..0000000 --- a/profiling/NATIVE_DEBUGGING_TESTING.md +++ /dev/null @@ -1,178 +0,0 @@ -# Native Debugging Testing Plan - -Testing native stepping for Lucee7+ debugger without JDWP. - -## Test Infrastructure - -### Option 1: Local Tomcat (Recommended for interactive testing) - -Use the existing Lucee7 Tomcat setup at `D:\lucee7\tomcat`: - -```cmd -rem Start Tomcat with luceedebug agent -cd D:\lucee7\tomcat\bin -set LUCEE_DEBUGGER_ENABLED=true -set lucee_logging_force_level=trace -set lucee_logging_force_appender=console -startup.bat -``` - -Then connect VS Code debugger to port 10000 and test stepping manually. - -### Option 2: Script-runner (For automated headless tests) - -Use script-runner for headless CFML execution: - -```cmd -ant -buildfile "D:\work\script-runner" ^ - -DluceeJar="D:\work\lucee7\loader\target\lucee-7.1.0.7-ALPHA.jar" ^ - -Dwebroot="D:\work\lucee-extensions\luceedebug\profiling" ^ - -Dexecute="test-debugger-frames.cfm" ^ - -Djdwp="true" ^ - -DjdwpPort="9999" ^ - -DjavaAgent="D:\work\lucee-extensions\luceedebug\luceedebug\build\libs\luceedebug-2.0.15.jar" ^ - -DjavaAgentArgs="jdwpHost=localhost,jdwpPort=9999,debugHost=0.0.0.0,debugPort=10000,jarPath=..." -``` - -**Limitation**: Script-runner is headless (no HTTP server), so DAP tests that require triggering breakpoints via HTTP won't work. - -### Option 3: Two-instance DAP testing - -For full DAP protocol testing (breakpoints, stepping via DAP commands): - -1. **Instance A (debuggee)**: Lucee with luceedebug, HTTP server on 8888, DAP on 10000 -2. **Instance B (test runner)**: Lucee WITHOUT debugger, runs test script - -The test script (Instance B) connects to DAP (10000), sets breakpoints, triggers HTTP requests to Instance A (8888), and verifies stepping behavior. - -## Test Files - -| File | Purpose | -|------|---------| -| `test-debugger-frames.cfm` | Tests native `getDebuggerFrames()` API | -| `test-breakpoint-bif.cfm` | Tests `breakpoint()` BIF | -| `dap-target.cfm` | Simple target for DAP breakpoint tests | -| `dap-step-target.cfm` | Target with nested functions for stepping tests | -| `test-dap-breakpoint.cfm` | DAP breakpoint test (needs 2 instances) | -| `test-dap-stepping.cfm` | DAP stepping test (needs 2 instances) | -| `DapClient.cfc` | CFML DAP protocol client | - -## Test Scenarios - -### 1. Native Frame API (no DAP) - -**Script**: `test-debugger-frames.cfm` -**Tests**: - -- [x] `DEBUGGER_ENABLED` flag detection -- [x] `getDebuggerFrames()` returns correct frame count -- [x] Frame contains function name, file path, locals, arguments -- [x] Nested function calls show correct stack - -### 2. Breakpoint BIF (no DAP) - -**Script**: `test-breakpoint-bif.cfm` -**Tests**: - -- [ ] `breakpoint()` suspends execution when debugger enabled -- [ ] `breakpoint(false)` does not suspend -- [ ] `breakpoint(true, "label")` shows label in debugger -- [ ] `breakpoint()` is no-op when debugger disabled - -### 3. DAP Breakpoints - -**Script**: `test-dap-breakpoint.cfm` -**Requires**: Two Lucee instances -**Tests**: - -- [ ] DAP connection and initialization -- [ ] Set breakpoints via `setBreakpoints` command -- [ ] Breakpoint hit sends `stopped` event -- [ ] Stack trace shows correct file/line -- [ ] Variables visible in scopes -- [ ] Continue resumes execution - -### 4. DAP Stepping - -**Script**: `test-dap-stepping.cfm` -**Requires**: Two Lucee instances -**Tests**: - -- [ ] **Step Over** (`next`): Executes function call, stops on next line -- [ ] **Step In** (`stepIn`): Enters function, stops on first line -- [ ] **Step Out** (`stepOut`): Runs to function return, stops in caller -- [ ] Step + breakpoint: Stepping stops at breakpoints too -- [ ] Nested stepping: Step in/out through multiple call levels - -## Manual Testing with VS Code - -1. Start Lucee7 Tomcat with luceedebug agent -2. Open VS Code, create launch.json: - ```json - { - "type": "cfml", - "request": "attach", - "name": "Attach to Lucee", - "hostName": "localhost", - "port": 10000 - } - ``` -3. Set breakpoint in a .cfm file -4. Trigger the page via browser -5. Test stepping buttons: Step Over (F10), Step In (F11), Step Out (Shift+F11) - -## Expected Stepping Behavior - -Given this code: -```cfml -function inner() { - var x = 1; // line 2 - return x; // line 3 -} - -function outer() { - var a = 0; // line 7 - var b = inner(); // line 8 - step in goes to line 2 - var c = b + 1; // line 9 - step over from line 8 goes here - return c; // line 10 -} - -result = outer(); // line 13 - breakpoint here -done = true; // line 14 -``` - -| Action | From | To | Stack Depth Change | -|--------|------|----|--------------------| -| Step Over | line 13 | line 14 | same | -| Step In | line 13 | line 7 | +1 | -| Step In | line 8 | line 2 | +1 | -| Step Over | line 8 | line 9 | same | -| Step Out | line 2 | line 9 | -1 | -| Step Out | line 7 | line 14 | -1 | - -## Debugging Test Failures - -### Enable trace logging - -```cmd -set lucee_logging_force_level=trace -set lucee_logging_force_appender=console -``` - -### Check luceedebug output - -Look for `[luceedebug]` prefixed messages: - -- `Registered native debugger listener` - listener registered OK -- `Native-only mode: true` - using native breakpoints -- `Added native breakpoint: /path/file.cfm:10` - breakpoint set -- `Native suspend: thread=123 file=... line=10` - breakpoint hit -- `Start stepping: thread=123 mode=STEP_OVER depth=3` - stepping started -- `Resuming native thread: 123` - continue/step executed - -### Common issues - -1. **No suspend on breakpoint**: Check `LUCEE_DEBUGGER_ENABLED=true` -2. **DebuggerRegistry not found**: Lucee version too old (need 7.1+) -3. **Stepping doesn't stop**: Check `shouldSuspend()` logic -4. **Wrong line after step**: Check stack depth calculation diff --git a/profiling/README.md b/profiling/README.md deleted file mode 100644 index e3e2abc..0000000 --- a/profiling/README.md +++ /dev/null @@ -1,223 +0,0 @@ -# luceedebug Profiling - -This directory contains tools for profiling luceedebug performance using Java Flight Recorder (JFR). - -## Findings Summary (2025-12-03) - -### Performance Overhead - -Benchmark results with 100,000 iterations on Lucee 7.1.0.7-ALPHA: - -| Benchmark | Baseline | With Agent | Overhead | -|-----------|----------|------------|----------| -| Simple function calls | 111ms | 175ms | **+58%** | -| Multi-line function | 99ms | 155ms | **+57%** | -| Nested function calls | 132ms | 236ms | **+79%** | -| Recursive calls (depth=10) | 373ms | 540ms | **+45%** | -| Mixed workload | 323ms | 593ms | **+84%** | - -**Key observation:** ~50-80% overhead with agent loaded but NOT actively debugging. - -### Real-World Test: lucee-spreadsheet - -| Test | Baseline | With Agent | Overhead | -|------|----------|------------|----------| -| Spreadsheet test suite | 20s | 25s | **+25%** | - -The real-world overhead is lower (~25%) because: - -- Synthetic benchmarks hammer function calls exclusively -- Real code spends time in I/O, Java libraries, etc. (not instrumented) -- Class loading overhead is amortised over more work - -### Hot Methods (from JFR) - -**Synthetic benchmark** - most frequently sampled luceedebug methods: - -1. `luceedebug_stepNotificationEntry_step` - Called on every CFML line -2. `getTopmostFrame` - Called from step notification -3. `maybeUpdateTopmostFrame` - Called from step notification -4. `maybe_pushCfFrame_worker` - Called on function entry -5. `pushCfFrame` - Called on function entry -6. `popCfFrame` - Called on function exit - -**Real-world (lucee-spreadsheet)** - different profile: - -1. **ASM ClassReader** (75+ samples) - bytecode transformation at class load -2. **udfCall wrappers** (108 samples) - function call wrappers -3. **pushCfFrame/popCfFrame** (31 samples) - frame management -4. **step notification** (5 samples) - much lower than synthetic - -In real apps, class loading overhead dominates initially, then function wrappers. - -### JFR Detailed Analysis (lucee-spreadsheet) - -| Metric | Baseline | With Agent | Difference | -|--------|----------|------------|------------| -| Duration | 20s | 25s | +5s (+25%) | -| ExecutionSamples | 392 | 526 | +134 | -| ClassLoad events | 10,784 | 11,227 | +443 extra classes | -| TLAB allocations | 3,050 | 7,457 | **+4,407 (+144%)** | -| GC pauses | 47 | 75 | +28 (+60%) | -| GC count | 42 | 64 | +22 (+52%) | - -**luceedebug-specific allocations** (not present in baseline): - -- 167× `Frame` - one per CFML function call -- 165× `Frame$FrameContext` - accompanies each Frame -- 360× `Long` (boxing from hash lookups) -- 350× `ArrayList$Itr` (iterator allocations) -- 120× `ConcurrentHashMap$Node` (hash map operations) - -**JDWP overhead** (from having debug port open): - -- 151× `ClassLoaderReference$VisibleClasses$ClassInfo` -- 106× `EventSetImpl` -- 104× `ClassTypeImpl` -- 74× `ClassPrepare` events - -**Lock contention**: No luceedebug-related lock contention detected. All `JavaMonitorEnter` events were in `SecureRandom` (unrelated to luceedebug). - -**Key insight**: Memory pressure is significant - 144% more TLAB allocations leads to 52% more GC cycles. Frame/FrameContext allocations per function call are a prime optimisation target. - -### JIT Inlining Analysis - -From `jdk.CompilerInlining` events, checking which hot methods get inlined: - -| Method | Call site | Inlined? | Reason | -|--------|-----------|----------|--------| -| `Thread.currentThread()` | maybe_pushCfFrame_worker | ✅ Yes | intrinsic | -| `ConcurrentMap.get()` | maybe_pushCfFrame_worker | ❌ No | "no static binding" (interface call) | -| `ArrayList.size()` | maybe_pushCfFrame_worker | ✅ Yes | inline | -| `Thread.currentThread()` | stepNotificationEntry_step | ✅ Yes | intrinsic | -| `ConcurrentHashMap.get()` | stepNotificationEntry_step | ❌ No | "no static binding" | - -**Key insight**: The `ConcurrentMap.get()` calls cannot be inlined because they're interface method calls. This happens on every function entry (`maybe_pushCfFrame_worker`) and every line (`stepNotificationEntry_step`). Using `ThreadLocal` instead of `ConcurrentHashMap` lookups for frame stacks would allow better JIT optimisation. - -### Stack Overflow Issue - -**IMPORTANT:** luceedebug can cause StackOverflowError on complex codebases. - -The instrumentation wraps every function call: - -``` -original: udfCall() -> actual code -with luceedebug: udfCall() -> udfCall__luceedebug__udfCall() -> actual code -``` - -This doubles stack frame usage. The lucee-docs build with Pygments syntax highlighting hits the recursion limit and fails with StackOverflowError when luceedebug is attached. - -**Workaround:** Increase stack size with `-Xss2m` or similar. - -### Optimization Priorities - -Based on profiling, the highest-impact optimizations would be: - -1. **Fast-path for step notification** - Add `stepRequestByThread.isEmpty()` check to exit early when not stepping -2. **Pre-sized collections** - ArrayList for frame stacks, HashMap for line maps -3. **ThreadLocal stepping flag** - Avoid hash lookups entirely when not stepping - -See `PERFORMANCE_PLAN.md` in project root for detailed optimization plan. - ---- - -## Overview - -luceedebug instruments every CFML function call and line execution. To understand the overhead, we need to profile: - -1. **Baseline** - Lucee running WITHOUT luceedebug -2. **Attached** - Lucee running WITH luceedebug (debugger not connected) -3. **Connected** - Lucee running WITH luceedebug AND debugger connected -4. **Stepping** - Lucee running WITH active step-through debugging - -## Files - -- `benchmark.cfm` - CFML script that stresses the hot paths (function calls, line stepping) -- `profile-baseline.bat` - Run benchmark without luceedebug (baseline) -- `profile-with-agent.bat` - Run benchmark with luceedebug agent loaded -- `profile-baseline-docs.bat` - Run lucee-docs build without luceedebug -- `profile-with-agent-docs.bat` - Run lucee-docs build with luceedebug (may fail with StackOverflow) -- `profile-baseline-spreadsheet.bat` - Run lucee-spreadsheet tests without luceedebug -- `profile-with-agent-spreadsheet.bat` - Run lucee-spreadsheet tests with luceedebug -- `compare-results.bat` - Compare baseline vs with-agent results - -## Prerequisites - -1. Build luceedebug: - - ```cmd - cd d:\work\lucee-extensions\luceedebug\luceedebug - gradlew shadowJar - ``` - -2. Ensure script-runner is available at `D:\work\script-runner` - -3. (Optional) For docs build tests, ensure lucee-docs is at `D:\work\lucee-docs` - -## Running the Profiles - -### 1. Baseline (No luceedebug) - -```cmd -profile-baseline.bat -``` - -This runs the benchmark with just Lucee, no debugger. Establishes the baseline performance. - -### 2. With luceedebug Agent - -```cmd -profile-with-agent.bat -``` - -This runs with luceedebug loaded but no debugger connected. Shows the passive overhead of having the agent attached. - -### 3. Compare Results - -```cmd -compare-results.bat -``` - -Shows side-by-side timing comparison. - -## Analysing JFR Results - -Open the `.jfr` files in JDK Mission Control (JMC) or use command line: - -```cmd -rem Summary -jfr summary output\with-agent.jfr - -rem Hot methods -jfr print --events jdk.ExecutionSample output\with-agent.jfr - -rem Count luceedebug methods in samples -jfr print --events jdk.ExecutionSample output\with-agent.jfr | grep -oE "luceedebug[^)]*" | sort | uniq -c | sort -rn - -rem Convert to JSON for scripting -jfr print --json output\with-agent.jfr > with-agent.json -``` - -### Key Things to Look For - -1. **CPU Hotspots** - Which methods consume the most CPU? - - Look for `luceedebug.*` methods in the flame graph - - Compare time spent in `pushCfFrame`/`popCfFrame` vs actual CFML execution - -2. **Lock Contention** - Are there synchronization bottlenecks? - - Check `jdk.JavaMonitorEnter` events - - Look for `ValTracker`, `DebugManager` lock waits - -3. **Allocations** - Memory pressure from debugging? - - Check `jdk.ObjectAllocationInNewTLAB` and `jdk.ObjectAllocationOutsideTLAB` - - Look for `Optional`, `WeakReference`, `ArrayList` allocations in hot paths - -4. **GC Impact** - Is the debugger causing more GC? - - Compare GC pause times and frequency between baseline and with-agent runs - -## Notes - -- The benchmark uses `systemOutput()` not `writeOutput()` for script-runner compatibility -- ITERATIONS can be adjusted in `benchmark.cfm` for longer/shorter runs -- For stepping overhead analysis, you'd need to manually step through with VS Code -- Real-world workloads (like lucee-docs) may hit stack limits due to doubled frame usage diff --git a/profiling/benchmark.cfm b/profiling/benchmark.cfm deleted file mode 100644 index 33e5dfb..0000000 --- a/profiling/benchmark.cfm +++ /dev/null @@ -1,115 +0,0 @@ - -/** - * luceedebug Performance Benchmark - * - * This script stresses the hot paths in luceedebug: - * - Function calls (pushCfFrame/popCfFrame) - * - Line stepping (luceedebug_stepNotificationEntry_step) - * - Nested calls and deep stacks - * - * Run with JFR enabled to profile overhead. - */ - -// Configuration -ITERATIONS = 100000; -NESTED_DEPTH = 10; - -// Simple function call - minimal overhead baseline -function simpleFunc( n ) { - return n + 1; -} - -// Recursive function - tests deep call stacks -function recursiveFunc( depth ) { - if ( depth <= 0 ) { - return 0; - } - return 1 + recursiveFunc( depth - 1 ); -} - -// Function with multiple lines - stresses line stepping -function multiLineFunc( n ) { - var a = n; - var b = a + 1; - var c = b + 2; - var d = c + 3; - var e = d + 4; - var f = e + 5; - var g = f + 6; - var h = g + 7; - var i = h + 8; - var j = i + 9; - return j; -} - -// Function that calls other functions - nested calls -function callerFunc( n ) { - var x = simpleFunc( n ); - var y = multiLineFunc( x ); - return y; -} - -// Warmup -systemOutput( "Warming up...", true ); -for ( i = 1; i <= 1000; i++ ) { - simpleFunc( i ); - multiLineFunc( i ); - callerFunc( i ); -} - -// Benchmark: Simple function calls -systemOutput( "", true ); -systemOutput( "=== Benchmark: Simple function calls (#ITERATIONS# iterations) ===", true ); -start = getTickCount(); -for ( i = 1; i <= ITERATIONS; i++ ) { - simpleFunc( i ); -} -elapsed = getTickCount() - start; -systemOutput( "Time: #elapsed#ms (#ITERATIONS / elapsed * 1000# calls/sec)", true ); - -// Benchmark: Multi-line function -systemOutput( "", true ); -systemOutput( "=== Benchmark: Multi-line function (#ITERATIONS# iterations) ===", true ); -start = getTickCount(); -for ( i = 1; i <= ITERATIONS; i++ ) { - multiLineFunc( i ); -} -elapsed = getTickCount() - start; -systemOutput( "Time: #elapsed#ms", true ); - -// Benchmark: Nested calls -systemOutput( "", true ); -systemOutput( "=== Benchmark: Nested function calls (#ITERATIONS# iterations) ===", true ); -start = getTickCount(); -for ( i = 1; i <= ITERATIONS; i++ ) { - callerFunc( i ); -} -elapsed = getTickCount() - start; -systemOutput( "Time: #elapsed#ms", true ); - -// Benchmark: Deep recursion -systemOutput( "", true ); -systemOutput( "=== Benchmark: Recursive calls (depth=#NESTED_DEPTH#, #ITERATIONS# iterations) ===", true ); -start = getTickCount(); -for ( i = 1; i <= ITERATIONS; i++ ) { - recursiveFunc( NESTED_DEPTH ); -} -elapsed = getTickCount() - start; -systemOutput( "Time: #elapsed#ms", true ); - -// Benchmark: Mixed workload -systemOutput( "", true ); -systemOutput( "=== Benchmark: Mixed workload (#ITERATIONS# iterations) ===", true ); -start = getTickCount(); -for ( i = 1; i <= ITERATIONS; i++ ) { - simpleFunc( i ); - multiLineFunc( i ); - callerFunc( i ); - recursiveFunc( 5 ); -} -elapsed = getTickCount() - start; -systemOutput( "Time: #elapsed#ms", true ); - -systemOutput( "", true ); -systemOutput( "Benchmark complete!", true ); - diff --git a/profiling/compare-results.bat b/profiling/compare-results.bat deleted file mode 100644 index 8fbb4ea..0000000 --- a/profiling/compare-results.bat +++ /dev/null @@ -1,55 +0,0 @@ -@echo off -setlocal - -rem ============================================================ -rem Compare baseline vs with-agent profiling results -rem ============================================================ - -set OUTPUT_DIR=%~dp0output - -echo. -echo ============================================================ -echo PERFORMANCE COMPARISON -echo ============================================================ -echo. - -echo --- BASELINE (no luceedebug) --- -if exist "%OUTPUT_DIR%\baseline-output.txt" ( - type "%OUTPUT_DIR%\baseline-output.txt" | findstr /C:"Time:" -) else ( - echo No baseline results found. Run profile-baseline.bat first. -) - -echo. -echo --- WITH AGENT (luceedebug loaded) --- -if exist "%OUTPUT_DIR%\with-agent-output.txt" ( - type "%OUTPUT_DIR%\with-agent-output.txt" | findstr /C:"Time:" -) else ( - echo No agent results found. Run profile-with-agent.bat first. -) - -echo. -echo ============================================================ -echo JFR FILES -echo ============================================================ -echo. - -if exist "%OUTPUT_DIR%\baseline.jfr" ( - echo Baseline: %OUTPUT_DIR%\baseline.jfr -) else ( - echo Baseline: NOT FOUND -) - -if exist "%OUTPUT_DIR%\with-agent.jfr" ( - echo With Agent: %OUTPUT_DIR%\with-agent.jfr -) else ( - echo With Agent: NOT FOUND -) - -echo. -echo To analyse in JDK Mission Control: -echo jmc -open "%OUTPUT_DIR%\baseline.jfr" -echo jmc -open "%OUTPUT_DIR%\with-agent.jfr" -echo. - -endlocal diff --git a/profiling/dap-step-target.cfm b/profiling/dap-step-target.cfm deleted file mode 100644 index 5f3db1c..0000000 --- a/profiling/dap-step-target.cfm +++ /dev/null @@ -1,24 +0,0 @@ - -// Target script for DAP stepping tests. -// Tests stepIn, stepOver, stepOut by calling nested functions. - -function innerFunc( required string value ) { - var inner1 = "inner: #arguments.value#"; // line 6 - var inner2 = inner1 & "!"; // line 7 - return inner2; // line 8 -} - -function outerFunc( required string name ) { - var before = "before"; // line 12 - var result = innerFunc( arguments.name ); // line 13 - stepIn goes here - var after = "after: #result#"; // line 14 - stepOver stays here - return after; // line 15 -} - -// Main execution -var startLine = "start"; // line 19 -var output = outerFunc( "test" ); // line 20 - breakpoint here -var endLine = "end: #output#"; // line 21 - -systemOutput( "dap-step-target.cfm completed: #endLine#", true ); - diff --git a/profiling/dap-target.cfm b/profiling/dap-target.cfm deleted file mode 100644 index 6217038..0000000 --- a/profiling/dap-target.cfm +++ /dev/null @@ -1,14 +0,0 @@ - -// Simple target script for DAP testing. -// Set a breakpoint on the line inside testFunc to test debugging. - -function testFunc( required string name ) { - var greeting = "Hello, #arguments.name#!"; // <- set breakpoint here (line 7) - var timestamp = now(); - return greeting; -} - -result = testFunc( "World" ); - -systemOutput( "dap-target.cfm completed: #result#", true ); - diff --git a/profiling/profile-baseline-docs.bat b/profiling/profile-baseline-docs.bat deleted file mode 100644 index fb875b9..0000000 --- a/profiling/profile-baseline-docs.bat +++ /dev/null @@ -1,34 +0,0 @@ -@echo off -setlocal - -rem ============================================================ -rem Profile luceedebug - BASELINE using lucee-docs build -rem ============================================================ - -set SCRIPT_RUNNER=D:\work\script-runner -set DOCS_DIR=D:\work\lucee-docs -set OUTPUT_DIR=D:\work\lucee-extensions\luceedebug\profiling\output -set JFC_CONFIG=D:\work\lucee-testlab\jfc-high-frequency.jfc - -rem Extensions from lucee-docs build -set EXTENSIONS=60772C12-F179-D555-8E2CD2B4F7428718;version=4.0.0.0,D46B46A9-A0E3-44E1-D972A04AC3A8DC10;version=2.0.0.1,EFDEB172-F52E-4D84-9CD1A1F561B3DFC8;version=3.0.0.163-RC,FAD67145-E3AE-30F8-1C11A6CCF544F0B7;version=2.0.0.3,6E2CB28F-98FB-4B51-B6BE6C64ADF35473;version=1.0.0.6,DF28D0A4-6748-44B9-A2FDC12E4E2E4D38;version=1.5.0.5,7891D723-8F78-45F5-B7E333A22F8467CA;version=1.0.0.9,261114AC-7372-4CA8-BA7090895E01682D;version=1.0.0.5,A03F4335-BDEF-44DE-946FB16C47802F96;version=1.0.0.0-RC,3F9DFF32-B555-449D-B0EB5DB723044045;version=3.0.0.17 - -rem Create output directory -if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" - -echo. -echo ============================================================ -echo BASELINE PROFILE - lucee-docs build without luceedebug -echo ============================================================ -echo. - -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeVersionQuery="7/all/jar" -Dwebroot="%DOCS_DIR%" -Dexecute="build.cfm" -Dextensions="%EXTENSIONS%" -DFlightRecording="true" -DFlightRecordingFilename="%OUTPUT_DIR%\baseline-docs.jfr" -DFlightRecordingSettings="%JFC_CONFIG%" -DuniqueWorkingDir="true" -DpostCleanup="false" > "%OUTPUT_DIR%\baseline-docs-output.txt" 2>&1 - -echo. -echo Results saved to: %OUTPUT_DIR%\baseline-docs-output.txt -echo JFR saved to: %OUTPUT_DIR%\baseline-docs.jfr -echo. - -type "%OUTPUT_DIR%\baseline-docs-output.txt" | findstr /C:"Total time:" /C:"BUILD" - -endlocal diff --git a/profiling/profile-baseline-spreadsheet.bat b/profiling/profile-baseline-spreadsheet.bat deleted file mode 100644 index 1a90451..0000000 --- a/profiling/profile-baseline-spreadsheet.bat +++ /dev/null @@ -1,31 +0,0 @@ -@echo off -setlocal - -rem ============================================================ -rem Profile luceedebug - BASELINE using lucee-spreadsheet tests -rem ============================================================ - -set SCRIPT_RUNNER=D:\work\script-runner -set SPREADSHEET_DIR=D:\work\lucee-spreadsheet -set OUTPUT_DIR=D:\work\lucee-extensions\luceedebug\profiling\output -set JFC_CONFIG=D:\work\lucee-testlab\jfc-high-frequency.jfc - -rem Create output directory -if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" - -echo. -echo ============================================================ -echo BASELINE PROFILE - lucee-spreadsheet without luceedebug -echo ============================================================ -echo. - -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeVersionQuery="7/all/jar" -Dwebroot="%SPREADSHEET_DIR%" -Dexecute="/test/index.cfm" -DFlightRecording="true" -DFlightRecordingFilename="%OUTPUT_DIR%\baseline-spreadsheet.jfr" -DFlightRecordingSettings="%JFC_CONFIG%" -DpostCleanup="false" > "%OUTPUT_DIR%\baseline-spreadsheet-output.txt" 2>&1 - -echo. -echo Results saved to: %OUTPUT_DIR%\baseline-spreadsheet-output.txt -echo JFR saved to: %OUTPUT_DIR%\baseline-spreadsheet.jfr -echo. - -type "%OUTPUT_DIR%\baseline-spreadsheet-output.txt" | findstr /C:"Total time:" /C:"BUILD" - -endlocal diff --git a/profiling/profile-baseline.bat b/profiling/profile-baseline.bat deleted file mode 100644 index fe20c46..0000000 --- a/profiling/profile-baseline.bat +++ /dev/null @@ -1,31 +0,0 @@ -@echo off -setlocal - -rem ============================================================ -rem Profile luceedebug - BASELINE (no agent) -rem ============================================================ - -set SCRIPT_RUNNER=D:\work\script-runner -set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling -set OUTPUT_DIR=%PROFILING_DIR%\output -set JFC_CONFIG=D:\work\lucee-testlab\jfc-high-frequency.jfc - -rem Create output directory -if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" - -echo. -echo ============================================================ -echo BASELINE PROFILE - Lucee without luceedebug -echo ============================================================ -echo. - -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeVersionQuery="7/all/jar" -Dwebroot="%PROFILING_DIR%" -Dexecute="benchmark.cfm" -DFlightRecording="true" -DFlightRecordingFilename="%OUTPUT_DIR%\baseline.jfr" -DFlightRecordingSettings="%JFC_CONFIG%" -DpostCleanup="false" > "%OUTPUT_DIR%\baseline-output.txt" 2>&1 - -echo. -echo Results saved to: %OUTPUT_DIR%\baseline-output.txt -echo JFR saved to: %OUTPUT_DIR%\baseline.jfr -echo. - -type "%OUTPUT_DIR%\baseline-output.txt" | findstr /C:"Time:" /C:"===" /C:"Benchmark" - -endlocal diff --git a/profiling/profile-local-lucee.bat b/profiling/profile-local-lucee.bat deleted file mode 100644 index 2ba9d93..0000000 --- a/profiling/profile-local-lucee.bat +++ /dev/null @@ -1,52 +0,0 @@ -@echo off -setlocal - -rem ============================================================ -rem Profile local Lucee7 build - test native debug overhead -rem ============================================================ - -set SCRIPT_RUNNER=D:\work\script-runner -set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling -set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.0.1.97-SNAPSHOT.jar -set OUTPUT_DIR=%PROFILING_DIR%\output - -rem Check if Lucee JAR exists -if not exist "%LUCEE_JAR%" ( - echo ERROR: Lucee JAR not found at %LUCEE_JAR% - echo Run: cd /d/work/lucee7/loader ^&^& ant fast - exit /b 1 -) - -rem Create output directory -if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" - -echo. -echo ============================================================ -echo Profile Local Lucee7 Build - Native Debug Overhead Test -echo ============================================================ -echo. -echo Lucee JAR: %LUCEE_JAR% -echo. - -echo --- Run 1: DEBUGGER_ENABLED=false --- -set LUCEE_DEBUGGER_ENABLED=false -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="benchmark.cfm" -DpostCleanup="false" -DpreCleanup="true" > "%OUTPUT_DIR%\local-debug-off.txt" 2>&1 - -echo. -echo --- Run 2: DEBUGGER_ENABLED=true --- -set LUCEE_DEBUGGER_ENABLED=true -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="benchmark.cfm" -DpostCleanup="false" -DpreCleanup="true" > "%OUTPUT_DIR%\local-debug-on.txt" 2>&1 - -echo. -echo ============================================================ -echo Results -echo ============================================================ -echo. -echo DEBUGGER_ENABLED=false: -type "%OUTPUT_DIR%\local-debug-off.txt" | findstr /C:"Time:" -echo. -echo DEBUGGER_ENABLED=true: -type "%OUTPUT_DIR%\local-debug-on.txt" | findstr /C:"Time:" -echo. - -endlocal diff --git a/profiling/profile-with-agent-docs.bat b/profiling/profile-with-agent-docs.bat deleted file mode 100644 index a9187a7..0000000 --- a/profiling/profile-with-agent-docs.bat +++ /dev/null @@ -1,46 +0,0 @@ -@echo off -setlocal - -rem ============================================================ -rem Profile luceedebug - WITH AGENT using lucee-docs build -rem ============================================================ - -set SCRIPT_RUNNER=D:\work\script-runner -set DOCS_DIR=D:\work\lucee-docs -set LUCEEDEBUG_JAR=D:\work\lucee-extensions\luceedebug\luceedebug\build\libs\luceedebug-2.0.15.jar -set OUTPUT_DIR=D:\work\lucee-extensions\luceedebug\profiling\output -set JFC_CONFIG=D:\work\lucee-testlab\jfc-high-frequency.jfc -set JDWP_PORT=9999 -set DEBUG_PORT=10000 - -rem Extensions from lucee-docs build -set EXTENSIONS=60772C12-F179-D555-8E2CD2B4F7428718;version=4.0.0.0,D46B46A9-A0E3-44E1-D972A04AC3A8DC10;version=2.0.0.1,EFDEB172-F52E-4D84-9CD1A1F561B3DFC8;version=3.0.0.163-RC,FAD67145-E3AE-30F8-1C11A6CCF544F0B7;version=2.0.0.3,6E2CB28F-98FB-4B51-B6BE6C64ADF35473;version=1.0.0.6,DF28D0A4-6748-44B9-A2FDC12E4E2E4D38;version=1.5.0.5,7891D723-8F78-45F5-B7E333A22F8467CA;version=1.0.0.9,261114AC-7372-4CA8-BA7090895E01682D;version=1.0.0.5,A03F4335-BDEF-44DE-946FB16C47802F96;version=1.0.0.0-RC,3F9DFF32-B555-449D-B0EB5DB723044045;version=3.0.0.17 - -rem Check if luceedebug JAR exists -if not exist "%LUCEEDEBUG_JAR%" ( - echo ERROR: luceedebug JAR not found at %LUCEEDEBUG_JAR% - echo Run: cd luceedebug ^&^& gradlew shadowJar - exit /b 1 -) - -rem Create output directory -if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" - -echo. -echo ============================================================ -echo AGENT PROFILE - lucee-docs build with luceedebug -echo ============================================================ -echo. -echo luceedebug JAR: %LUCEEDEBUG_JAR% -echo. - -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeVersionQuery="7/all/jar" -Dwebroot="%DOCS_DIR%" -Dexecute="build.cfm" -Dextensions="%EXTENSIONS%" -Djdwp="true" -DjdwpPort="%JDWP_PORT%" -DjavaAgent="%LUCEEDEBUG_JAR%" -DjavaAgentArgs="jdwpHost=localhost,jdwpPort=%JDWP_PORT%,debugHost=0.0.0.0,debugPort=%DEBUG_PORT%,jarPath=%LUCEEDEBUG_JAR%" -DFlightRecording="true" -DFlightRecordingFilename="%OUTPUT_DIR%\with-agent-docs.jfr" -DFlightRecordingSettings="%JFC_CONFIG%" -DuniqueWorkingDir="true" -DpostCleanup="false" > "%OUTPUT_DIR%\with-agent-docs-output.txt" 2>&1 - -echo. -echo Results saved to: %OUTPUT_DIR%\with-agent-docs-output.txt -echo JFR saved to: %OUTPUT_DIR%\with-agent-docs.jfr -echo. - -type "%OUTPUT_DIR%\with-agent-docs-output.txt" | findstr /C:"Total time:" /C:"BUILD" /C:"luceedebug" - -endlocal diff --git a/profiling/profile-with-agent-spreadsheet.bat b/profiling/profile-with-agent-spreadsheet.bat deleted file mode 100644 index f2b918a..0000000 --- a/profiling/profile-with-agent-spreadsheet.bat +++ /dev/null @@ -1,43 +0,0 @@ -@echo off -setlocal - -rem ============================================================ -rem Profile luceedebug - WITH AGENT using lucee-spreadsheet tests -rem ============================================================ - -set SCRIPT_RUNNER=D:\work\script-runner -set SPREADSHEET_DIR=D:\work\lucee-spreadsheet -set LUCEEDEBUG_JAR=D:\work\lucee-extensions\luceedebug\luceedebug\build\libs\luceedebug-2.0.15.jar -set OUTPUT_DIR=D:\work\lucee-extensions\luceedebug\profiling\output -set JFC_CONFIG=D:\work\lucee-testlab\jfc-high-frequency.jfc -set JDWP_PORT=9999 -set DEBUG_PORT=10000 - -rem Check if luceedebug JAR exists -if not exist "%LUCEEDEBUG_JAR%" ( - echo ERROR: luceedebug JAR not found at %LUCEEDEBUG_JAR% - echo Run: cd luceedebug ^&^& gradlew shadowJar - exit /b 1 -) - -rem Create output directory -if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" - -echo. -echo ============================================================ -echo AGENT PROFILE - lucee-spreadsheet with luceedebug -echo ============================================================ -echo. -echo luceedebug JAR: %LUCEEDEBUG_JAR% -echo. - -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeVersionQuery="7/all/jar" -Dwebroot="%SPREADSHEET_DIR%" -Dexecute="/test/index.cfm" -Djdwp="true" -DjdwpPort="%JDWP_PORT%" -DjavaAgent="%LUCEEDEBUG_JAR%" -DjavaAgentArgs="jdwpHost=localhost,jdwpPort=%JDWP_PORT%,debugHost=0.0.0.0,debugPort=%DEBUG_PORT%,jarPath=%LUCEEDEBUG_JAR%" -DFlightRecording="true" -DFlightRecordingFilename="%OUTPUT_DIR%\with-agent-spreadsheet.jfr" -DFlightRecordingSettings="%JFC_CONFIG%" -DpostCleanup="false" > "%OUTPUT_DIR%\with-agent-spreadsheet-output.txt" 2>&1 - -echo. -echo Results saved to: %OUTPUT_DIR%\with-agent-spreadsheet-output.txt -echo JFR saved to: %OUTPUT_DIR%\with-agent-spreadsheet.jfr -echo. - -type "%OUTPUT_DIR%\with-agent-spreadsheet-output.txt" | findstr /C:"Total time:" /C:"BUILD" /C:"luceedebug" - -endlocal diff --git a/profiling/profile-with-agent.bat b/profiling/profile-with-agent.bat deleted file mode 100644 index abc1873..0000000 --- a/profiling/profile-with-agent.bat +++ /dev/null @@ -1,43 +0,0 @@ -@echo off -setlocal - -rem ============================================================ -rem Profile luceedebug - WITH AGENT (no debugger connected) -rem ============================================================ - -set SCRIPT_RUNNER=D:\work\script-runner -set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling -set LUCEEDEBUG_JAR=D:\work\lucee-extensions\luceedebug\luceedebug\build\libs\luceedebug-2.0.15.jar -set OUTPUT_DIR=%PROFILING_DIR%\output -set JFC_CONFIG=D:\work\lucee-testlab\jfc-high-frequency.jfc -set JDWP_PORT=9999 -set DEBUG_PORT=10000 - -rem Check if luceedebug JAR exists -if not exist "%LUCEEDEBUG_JAR%" ( - echo ERROR: luceedebug JAR not found at %LUCEEDEBUG_JAR% - echo Run: cd luceedebug ^&^& gradlew shadowJar - exit /b 1 -) - -rem Create output directory -if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" - -echo. -echo ============================================================ -echo AGENT PROFILE - Lucee with luceedebug (no debugger connected) -echo ============================================================ -echo. -echo luceedebug JAR: %LUCEEDEBUG_JAR% -echo. - -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeVersionQuery="7/all/jar" -Dwebroot="%PROFILING_DIR%" -Dexecute="benchmark.cfm" -Djdwp="true" -DjdwpPort="%JDWP_PORT%" -DjavaAgent="%LUCEEDEBUG_JAR%" -DjavaAgentArgs="jdwpHost=localhost,jdwpPort=%JDWP_PORT%,debugHost=0.0.0.0,debugPort=%DEBUG_PORT%,jarPath=%LUCEEDEBUG_JAR%" -DFlightRecording="true" -DFlightRecordingFilename="%OUTPUT_DIR%\with-agent.jfr" -DFlightRecordingSettings="%JFC_CONFIG%" -DpostCleanup="false" > "%OUTPUT_DIR%\with-agent-output.txt" 2>&1 - -echo. -echo Results saved to: %OUTPUT_DIR%\with-agent-output.txt -echo JFR saved to: %OUTPUT_DIR%\with-agent.jfr -echo. - -type "%OUTPUT_DIR%\with-agent-output.txt" | findstr /C:"Time:" /C:"===" /C:"Benchmark" /C:"luceedebug" - -endlocal diff --git a/profiling/test-breakpoint-bif.bat b/profiling/test-breakpoint-bif.bat deleted file mode 100644 index 5ab49d9..0000000 --- a/profiling/test-breakpoint-bif.bat +++ /dev/null @@ -1,25 +0,0 @@ -@echo off -setlocal - -set SCRIPT_RUNNER=D:\work\script-runner -set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling -set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.0.1.97-SNAPSHOT.jar - -if not exist "%LUCEE_JAR%" ( - echo ERROR: Lucee JAR not found at %LUCEE_JAR% - exit /b 1 -) - -echo. -echo === Test 1: DEBUGGER_ENABLED=false (should complete immediately) === -echo. -set LUCEE_DEBUGGER_ENABLED=false -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="test-breakpoint-bif.cfm" -DpostCleanup="false" -DpreCleanup="true" 2>&1 | findstr /C:"===" /C:"isDebuggerEnabled" /C:"breakpoint" /C:"Result" - -echo. -echo === Test 2: DEBUGGER_ENABLED=true (will suspend at breakpoint - Ctrl+C to cancel) === -echo. -set LUCEE_DEBUGGER_ENABLED=true -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="test-breakpoint-bif.cfm" -DpostCleanup="false" -DpreCleanup="true" 2>&1 | findstr /C:"===" /C:"isDebuggerEnabled" /C:"breakpoint" /C:"Result" - -endlocal diff --git a/profiling/test-breakpoint-bif.cfm b/profiling/test-breakpoint-bif.cfm deleted file mode 100644 index 62eff05..0000000 --- a/profiling/test-breakpoint-bif.cfm +++ /dev/null @@ -1,56 +0,0 @@ - -// Test script for breakpoint() BIF - -systemOutput( "=== Testing breakpoint() BIF ===" ); -systemOutput( "" ); - -systemOutput( "isDebuggerEnabled(): #isDebuggerEnabled()#" ); - -if ( !isDebuggerEnabled() ) { - systemOutput( "" ); - systemOutput( "Debugger is DISABLED - breakpoint() will be a no-op." ); - systemOutput( "Run with LUCEE_DEBUGGER_ENABLED=true to test suspension." ); - systemOutput( "" ); -} - -function testFunction( required string name ) { - var localVar = "Hello, #arguments.name#!"; - systemOutput( "Before breakpoint in testFunction( #arguments.name# )" ); - - // Simple breakpoint - breakpoint(); - - systemOutput( "After breakpoint in testFunction( #arguments.name# )" ); - return localVar; -} - -function conditionalBreakpointTest( required numeric value ) { - systemOutput( "Testing conditional breakpoint with value=#arguments.value#" ); - - // Only break when value > 5 - breakpoint( arguments.value > 5, "value > 5 breakpoint" ); - - return arguments.value * 2; -} - -// Test simple breakpoint -systemOutput( "" ); -systemOutput( "--- Test 1: Simple breakpoint ---" ); -result = testFunction( "World" ); -systemOutput( "Result: #result#" ); - -// Test conditional breakpoint (should NOT suspend) -systemOutput( "" ); -systemOutput( "--- Test 2: Conditional breakpoint (value=3, should NOT suspend) ---" ); -result = conditionalBreakpointTest( 3 ); -systemOutput( "Result: #result#" ); - -// Test conditional breakpoint (SHOULD suspend) -systemOutput( "" ); -systemOutput( "--- Test 3: Conditional breakpoint (value=10, SHOULD suspend) ---" ); -result = conditionalBreakpointTest( 10 ); -systemOutput( "Result: #result#" ); - -systemOutput( "" ); -systemOutput( "=== Test Complete ===" ); - diff --git a/profiling/test-dap-breakpoint.cfm b/profiling/test-dap-breakpoint.cfm deleted file mode 100644 index 51d9663..0000000 --- a/profiling/test-dap-breakpoint.cfm +++ /dev/null @@ -1,145 +0,0 @@ - -/** - * DAP Breakpoint Test - * - * Tests that native breakpoints work via the DAP protocol. - * - * IMPORTANT: This script must run on a DIFFERENT Lucee instance than the debuggee! - * Otherwise, when the breakpoint hits, this test script will also be frozen. - * - * Setup: - * - Instance A (test runner): Runs this script, NO debugger - * - Instance B (debuggee): Runs with luceedebug agent on port 10000, HTTP on 8888 - * - * Usage: - * Configure the variables below, then run this script. - */ - -// ========== Configuration ========== -DAP_HOST = "localhost"; -DAP_PORT = 10000; -DEBUGGEE_HTTP = "http://localhost:8888"; -TARGET_FILE = "/app/profiling/dap-target.cfm"; // Path as seen by debuggee -BREAKPOINT_LINE = 7; // Line inside testFunc -// =================================== - -systemOutput( "=== DAP Breakpoint Test ===" ); -systemOutput( "" ); - -dap = new DapClient( debug = true ); - -try { - // Connect to debugger - systemOutput( "Connecting to DAP server at #DAP_HOST#:#DAP_PORT#..." ); - dap.connect( DAP_HOST, DAP_PORT ); - systemOutput( "Connected!" ); - - // Initialize - systemOutput( "" ); - systemOutput( "Initializing DAP session..." ); - initResponse = dap.initialize(); - systemOutput( "Initialized. Capabilities: #serializeJSON( initResponse.body ?: {} )#" ); - - // Set breakpoint - systemOutput( "" ); - systemOutput( "Setting breakpoint at #TARGET_FILE#:#BREAKPOINT_LINE#..." ); - bpResponse = dap.setBreakpoints( TARGET_FILE, [ BREAKPOINT_LINE ] ); - systemOutput( "Breakpoints: #serializeJSON( bpResponse.body ?: {} )#" ); - - // Configuration done - systemOutput( "" ); - systemOutput( "Sending configurationDone..." ); - dap.configurationDone(); - systemOutput( "Ready!" ); - - // Trigger the target script via HTTP (in a separate thread so we don't block) - systemOutput( "" ); - systemOutput( "Triggering target script via HTTP..." ); - - httpResult = {}; - thread name="httpTrigger" httpResult=httpResult DEBUGGEE_HTTP=DEBUGGEE_HTTP { - try { - http url="#DEBUGGEE_HTTP#/profiling/dap-target.cfm" result="local.r" timeout=30; - httpResult.status = local.r.statusCode; - httpResult.content = local.r.fileContent; - } catch ( any e ) { - httpResult.error = e.message; - } - } - - // Wait for stopped event - systemOutput( "Waiting for breakpoint to hit..." ); - stoppedEvent = dap.waitForEvent( "stopped", 10000 ); - systemOutput( "" ); - systemOutput( "=== BREAKPOINT HIT ===" ); - systemOutput( "Thread ID: #stoppedEvent.body.threadId#" ); - systemOutput( "Reason: #stoppedEvent.body.reason#" ); - - // Get stack trace - systemOutput( "" ); - systemOutput( "Getting stack trace..." ); - stackResponse = dap.stackTrace( stoppedEvent.body.threadId ); - frames = stackResponse.body.stackFrames ?: []; - - systemOutput( "Stack frames (#frames.len()#):" ); - for ( var i = 1; i <= min( frames.len(), 5 ); i++ ) { - var frame = frames[ i ]; - systemOutput( " [#i#] #frame.name# at #frame.source.path ?: 'unknown'#:#frame.line#" ); - } - - // Verify we stopped at the right place - if ( frames.len() > 0 ) { - var topFrame = frames[ 1 ]; - if ( topFrame.line == BREAKPOINT_LINE ) { - systemOutput( "" ); - systemOutput( "SUCCESS: Stopped at expected line #BREAKPOINT_LINE#" ); - } else { - systemOutput( "" ); - systemOutput( "WARNING: Expected line #BREAKPOINT_LINE#, got #topFrame.line#" ); - } - - // Get scopes and variables for top frame - systemOutput( "" ); - systemOutput( "Getting scopes for frame #topFrame.id#..." ); - scopesResponse = dap.scopes( topFrame.id ); - scopes = scopesResponse.body.scopes ?: []; - - for ( var scope in scopes ) { - systemOutput( " Scope: #scope.name# (ref=#scope.variablesReference#)" ); - - if ( scope.variablesReference > 0 && scope.name == "Local" ) { - varsResponse = dap.getVariables( scope.variablesReference ); - vars = varsResponse.body.variables ?: []; - for ( var v in vars ) { - systemOutput( " #v.name# = #v.value#" ); - } - } - } - } - - // Continue execution - systemOutput( "" ); - systemOutput( "Continuing execution..." ); - dap.continueThread( stoppedEvent.body.threadId ); - - // Wait for HTTP request to complete - threadJoin( "httpTrigger", 5000 ); - - systemOutput( "" ); - if ( httpResult.keyExists( "error" ) ) { - systemOutput( "HTTP request error: #httpResult.error#" ); - } else { - systemOutput( "HTTP request completed: #httpResult.status ?: 'unknown'#" ); - } - - systemOutput( "" ); - systemOutput( "=== TEST COMPLETE ===" ); - -} catch ( any e ) { - systemOutput( "" ); - systemOutput( "ERROR: #e.message#" ); - systemOutput( e.stackTrace ); -} finally { - dap.disconnect(); -} - diff --git a/profiling/test-dap-stepping.bat b/profiling/test-dap-stepping.bat deleted file mode 100644 index 4e4f62c..0000000 --- a/profiling/test-dap-stepping.bat +++ /dev/null @@ -1,47 +0,0 @@ -@echo off -setlocal EnableDelayedExpansion - -rem ============================================================ -rem DAP Stepping Test -rem -rem This test requires TWO Lucee instances: -rem 1. Debuggee: Runs with luceedebug agent, serves HTTP on 8888 -rem 2. Test runner: Runs this test script, connects to DAP on 10000 -rem -rem Since script-runner runs headless, we need an alternative. -rem This script starts CommandBox for the debuggee. -rem ============================================================ - -set SCRIPT_RUNNER=D:\work\script-runner -set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling -set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.0.1.97-SNAPSHOT.jar -set LUCEEDEBUG_JAR=D:\work\lucee-extensions\luceedebug\luceedebug\build\libs\luceedebug-2.0.15.jar -set JDWP_PORT=9999 -set DEBUG_PORT=10000 - -echo. -echo ============================================================ -echo DAP Stepping Test -echo ============================================================ -echo. -echo This test requires TWO Lucee instances running simultaneously. -echo. -echo Option 1: Manual Setup -echo 1. Start debuggee with HTTP server (CommandBox or Tomcat) -echo - Lucee with luceedebug agent on DAP port 10000 -echo - HTTP on port 8888 -echo - Webroot: %PROFILING_DIR% -echo 2. Run this script to execute the test -echo. -echo Option 2: Use existing test infrastructure -echo Run test-native-frames-with-agent.bat to verify agent works -echo Then manually test stepping with VS Code -echo. -echo ============================================================ -echo. - -rem For now, just run the basic native frames test -echo Running native frames test first... -call "%PROFILING_DIR%\test-native-frames-with-agent.bat" - -endlocal diff --git a/profiling/test-dap-stepping.cfm b/profiling/test-dap-stepping.cfm deleted file mode 100644 index ad94c32..0000000 --- a/profiling/test-dap-stepping.cfm +++ /dev/null @@ -1,238 +0,0 @@ - -/** - * DAP Stepping Test - * - * Tests stepIn, stepOver, stepOut functionality via the DAP protocol. - * - * IMPORTANT: This script must run on a DIFFERENT Lucee instance than the debuggee! - * Otherwise, when the breakpoint hits, this test script will also be frozen. - * - * Setup: - * - Instance A (test runner): Runs this script, NO debugger - * - Instance B (debuggee): Runs with luceedebug agent on port 10000, HTTP on 8888 - * - * Usage: - * Configure the variables below, then run this script. - */ - -// ========== Configuration ========== -DAP_HOST = "localhost"; -DAP_PORT = 10000; -DEBUGGEE_HTTP = "http://localhost:8888"; -TARGET_FILE = "/app/profiling/dap-step-target.cfm"; // Path as seen by debuggee -BREAKPOINT_LINE = 20; // Line with outerFunc call -// =================================== - -systemOutput( "=== DAP Stepping Test ===", true ); -systemOutput( "", true ); - -// Track test results -testResults = { passed: 0, failed: 0, errors: [] }; - -function assert( required boolean condition, required string message ) { - if ( arguments.condition ) { - testResults.passed++; - systemOutput( " PASS: #arguments.message#", true ); - } else { - testResults.failed++; - testResults.errors.append( arguments.message ); - systemOutput( " FAIL: #arguments.message#", true ); - } -} - -function assertLine( required numeric expected, required numeric actual, required string context ) { - assert( arguments.actual == arguments.expected, "#arguments.context#: expected line #arguments.expected#, got #arguments.actual#" ); -} - -dap = new DapClient( debug = true ); - -try { - // Connect to debugger - systemOutput( "Connecting to DAP server at #DAP_HOST#:#DAP_PORT#...", true ); - dap.connect( DAP_HOST, DAP_PORT ); - systemOutput( "Connected!", true ); - - // Initialize - systemOutput( "", true ); - systemOutput( "Initializing DAP session...", true ); - initResponse = dap.initialize(); - systemOutput( "Initialized.", true ); - - // Set breakpoint - systemOutput( "", true ); - systemOutput( "Setting breakpoint at #TARGET_FILE#:#BREAKPOINT_LINE#...", true ); - bpResponse = dap.setBreakpoints( TARGET_FILE, [ BREAKPOINT_LINE ] ); - systemOutput( "Breakpoints set.", true ); - - // Configuration done - dap.configurationDone(); - systemOutput( "Ready!", true ); - - // Trigger the target script via HTTP (in a separate thread so we don't block) - systemOutput( "", true ); - systemOutput( "Triggering target script via HTTP...", true ); - - httpResult = {}; - thread name="httpTrigger" httpResult=httpResult DEBUGGEE_HTTP=DEBUGGEE_HTTP { - try { - http url="#DEBUGGEE_HTTP#/profiling/dap-step-target.cfm" result="local.r" timeout=60; - httpResult.status = local.r.statusCode; - httpResult.content = local.r.fileContent; - } catch ( any e ) { - httpResult.error = e.message; - } - } - - // ========== Test 1: Initial breakpoint hit ========== - systemOutput( "", true ); - systemOutput( "=== Test 1: Initial Breakpoint ===", true ); - - stoppedEvent = dap.waitForEvent( "stopped", 15000 ); - threadId = stoppedEvent.body.threadId; - systemOutput( "Thread ID: #threadId#", true ); - - stackResponse = dap.stackTrace( threadId ); - frames = stackResponse.body.stackFrames ?: []; - - if ( frames.len() > 0 ) { - assertLine( BREAKPOINT_LINE, frames[ 1 ].line, "Initial breakpoint" ); - } else { - testResults.failed++; - testResults.errors.append( "No stack frames at initial breakpoint" ); - } - - // ========== Test 2: Step Over ========== - systemOutput( "", true ); - systemOutput( "=== Test 2: Step Over (should stay at line 21) ===", true ); - - dap.stepOver( threadId ); - stoppedEvent = dap.waitForEvent( "stopped", 5000 ); - - stackResponse = dap.stackTrace( threadId ); - frames = stackResponse.body.stackFrames ?: []; - - if ( frames.len() > 0 ) { - assertLine( 21, frames[ 1 ].line, "Step over from line 20" ); - } - - // ========== Reset: Continue and hit breakpoint again ========== - systemOutput( "", true ); - systemOutput( "=== Resetting: Continue to end, re-trigger ===", true ); - - dap.continueThread( threadId ); - - // Wait for HTTP to finish - threadJoin( "httpTrigger", 5000 ); - systemOutput( "First run completed.", true ); - - // Re-trigger for stepIn test - httpResult = {}; - thread name="httpTrigger2" httpResult=httpResult DEBUGGEE_HTTP=DEBUGGEE_HTTP { - try { - http url="#DEBUGGEE_HTTP#/profiling/dap-step-target.cfm" result="local.r" timeout=60; - httpResult.status = local.r.statusCode; - httpResult.content = local.r.fileContent; - } catch ( any e ) { - httpResult.error = e.message; - } - } - - stoppedEvent = dap.waitForEvent( "stopped", 15000 ); - threadId = stoppedEvent.body.threadId; - - // ========== Test 3: Step Over (execute outerFunc, land on line 21) ========== - systemOutput( "", true ); - systemOutput( "=== Test 3: Step Over outerFunc call ===", true ); - - dap.stepOver( threadId ); - stoppedEvent = dap.waitForEvent( "stopped", 5000 ); - - stackResponse = dap.stackTrace( threadId ); - frames = stackResponse.body.stackFrames ?: []; - - if ( frames.len() > 0 ) { - assertLine( 21, frames[ 1 ].line, "Step over outerFunc" ); - } - - // ========== Test 4: Continue to end, re-trigger for stepIn ========== - systemOutput( "", true ); - systemOutput( "=== Test 4: Step In test ===", true ); - - dap.continueThread( threadId ); - threadJoin( "httpTrigger2", 5000 ); - - // Re-trigger for stepIn test - httpResult = {}; - thread name="httpTrigger3" httpResult=httpResult DEBUGGEE_HTTP=DEBUGGEE_HTTP { - try { - http url="#DEBUGGEE_HTTP#/profiling/dap-step-target.cfm" result="local.r" timeout=60; - httpResult.status = local.r.statusCode; - httpResult.content = local.r.fileContent; - } catch ( any e ) { - httpResult.error = e.message; - } - } - - stoppedEvent = dap.waitForEvent( "stopped", 15000 ); - threadId = stoppedEvent.body.threadId; - - // Step into outerFunc - dap.stepIn( threadId ); - stoppedEvent = dap.waitForEvent( "stopped", 5000 ); - - stackResponse = dap.stackTrace( threadId ); - frames = stackResponse.body.stackFrames ?: []; - - if ( frames.len() > 0 ) { - // Should be inside outerFunc, line 12 - assertLine( 12, frames[ 1 ].line, "Step into outerFunc" ); - } - - // ========== Test 5: Step Out ========== - systemOutput( "", true ); - systemOutput( "=== Test 5: Step Out ===", true ); - - dap.stepOut( threadId ); - stoppedEvent = dap.waitForEvent( "stopped", 5000 ); - - stackResponse = dap.stackTrace( threadId ); - frames = stackResponse.body.stackFrames ?: []; - - if ( frames.len() > 0 ) { - // Should be back at line 21 (after outerFunc call) - assertLine( 21, frames[ 1 ].line, "Step out of outerFunc" ); - } - - // Continue to complete - dap.continueThread( threadId ); - threadJoin( "httpTrigger3", 5000 ); - - // ========== Summary ========== - systemOutput( "", true ); - systemOutput( "========================================", true ); - systemOutput( "TEST SUMMARY", true ); - systemOutput( "========================================", true ); - systemOutput( "Passed: #testResults.passed#", true ); - systemOutput( "Failed: #testResults.failed#", true ); - - if ( testResults.errors.len() > 0 ) { - systemOutput( "", true ); - systemOutput( "Failures:", true ); - for ( var err in testResults.errors ) { - systemOutput( " - #err#", true ); - } - } - - if ( testResults.failed == 0 ) { - systemOutput( "", true ); - systemOutput( "ALL TESTS PASSED!", true ); - } - -} catch ( any e ) { - systemOutput( "", true ); - systemOutput( "ERROR: #e.message#", true ); - systemOutput( e.stackTrace, true ); -} finally { - dap.disconnect(); -} - diff --git a/profiling/test-debugger-frames.cfm b/profiling/test-debugger-frames.cfm deleted file mode 100644 index 3af3b06..0000000 --- a/profiling/test-debugger-frames.cfm +++ /dev/null @@ -1,68 +0,0 @@ - -// Test script for Lucee's new debugger frame support -// Run with: -Dlucee.debugger.enabled=true - -systemOutput( "=== Testing Debugger Frame Support ===" ); -systemOutput( "" ); - -// Check if debugger is enabled -pc = getPageContext(); -pci = pc.getClass().getName() == "lucee.runtime.PageContextImpl" ? pc : javaCast( "null", "" ); - -if ( isNull( pci ) ) { - systemOutput( "ERROR: Could not get PageContextImpl" ); - abort; -} - -// Check the static flag -debuggerEnabled = createObject( "java", "lucee.runtime.PageContextImpl" ).DEBUGGER_ENABLED; -systemOutput( "DEBUGGER_ENABLED: #debuggerEnabled#" ); - -if ( !debuggerEnabled ) { - systemOutput( "" ); - systemOutput( "Debugger is DISABLED. Run with -Dlucee.debugger.enabled=true" ); - systemOutput( "" ); - abort; -} - -// Test nested function calls -function level3( required string msg ) { - var localVar = "I am in level3"; - var frames = getPageContext().getDebuggerFrames(); - - systemOutput( "" ); - systemOutput( "Inside level3() - #arguments.msg#" ); - systemOutput( " localVar: #localVar#" ); - systemOutput( " Frame count: #arrayLen( frames )#" ); - - for ( var i = 1; i <= arrayLen( frames ); i++ ) { - var frame = frames[ i ]; - systemOutput( " Frame #i#: #frame.functionName# @ #frame.pageSource.getDisplayPath()#" ); - systemOutput( " - local keys: #structKeyList( frame.local )#" ); - systemOutput( " - arguments keys: #structKeyList( frame.arguments )#" ); - } - - return localVar; -} - -function level2( required numeric num ) { - var l2Var = "level2 local"; - return level3( "called from level2 with num=#arguments.num#" ); -} - -function level1() { - var l1Var = "level1 local"; - var result = level2( 42 ); - return result; -} - -// Run the test -systemOutput( "" ); -systemOutput( "Calling level1() -> level2() -> level3()..." ); -result = level1(); - -systemOutput( "" ); -systemOutput( "Result: #result#" ); -systemOutput( "" ); -systemOutput( "=== Test Complete ===" ); - diff --git a/profiling/test-extension.bat b/profiling/test-extension.bat deleted file mode 100644 index 274aa91..0000000 --- a/profiling/test-extension.bat +++ /dev/null @@ -1,46 +0,0 @@ -@echo off -setlocal - -rem ============================================================ -rem Test luceedebug extension deployment with local Lucee7 build -rem ============================================================ - -set SCRIPT_RUNNER=D:\work\script-runner -set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling -set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.1.0.7-ALPHA.jar -set EXTENSION_DIR=D:\work\lucee-extensions\luceedebug\luceedebug\build\extension - -rem Check if Lucee JAR exists -if not exist "%LUCEE_JAR%" ( - echo ERROR: Lucee JAR not found at %LUCEE_JAR% - echo Run: cd /d/work/lucee7/loader ^&^& ant fast - exit /b 1 -) - -rem Check if extension exists -if not exist "%EXTENSION_DIR%\luceedebug-extension-3.0.0.lex" ( - echo ERROR: Extension not found at %EXTENSION_DIR% - echo Run: cd /d/work/lucee-extensions/luceedebug ^&^& gradlew buildExtension - exit /b 1 -) - -echo. -echo ============================================================ -echo Testing Luceedebug Extension Deployment -echo ============================================================ -echo. -echo Lucee JAR: %LUCEE_JAR% -echo Extension: %EXTENSION_DIR% -echo. - -rem Enable debugger via env var - set port to enable -set LUCEE_DEBUGGER_PORT=10000 -set LUCEE_DEBUGGER_ENABLED=true - -rem Enable verbose logging -set LUCEE_LOGGING_FORCE_APPENDER=console -set LUCEE_LOGGING_FORCE_LEVEL=debug - -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -DextensionDir="%EXTENSION_DIR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="test-extension.cfm" -DpostCleanup="false" -DpreCleanup="true" - -endlocal diff --git a/profiling/test-extension.cfm b/profiling/test-extension.cfm deleted file mode 100644 index 5bbe5b3..0000000 --- a/profiling/test-extension.cfm +++ /dev/null @@ -1,78 +0,0 @@ - -// Test script for luceedebug extension deployment -// Run with: profiling\test-extension.bat - -function runTest() { - systemOutput( "============================================================", true ); - systemOutput( "Testing Luceedebug Extension Deployment", true ); - systemOutput( "============================================================", true ); - systemOutput( "", true ); - - // Check environment - systemOutput( "Environment:", true ); - systemOutput( " LUCEE_DEBUGGER_PORT: " & ( server.system.environment.LUCEE_DEBUGGER_PORT ?: "not set" ), true ); - systemOutput( " Lucee Version: " & server.lucee.version, true ); - systemOutput( "", true ); - - // Check if extension is installed via admin API - systemOutput( "Checking installed extensions...", true ); - try { - var admin = new Administrator( "server", "password" ); - var extensions = admin.getExtensions(); - var found = false; - for ( var ext in extensions ) { - if ( ext.id contains "DECEB" || ext.name contains "luceedebug" || ext.name contains "Luceedebug" ) { - systemOutput( " FOUND: #ext.name# v#ext.version# (id: #ext.id#)", true ); - found = true; - } - } - if ( !found ) { - systemOutput( " WARNING: luceedebug extension not found in installed list", true ); - systemOutput( " Available extensions:", true ); - for ( var ext in extensions ) { - systemOutput( " - #ext.name# (#ext.id#)", true ); - } - } - } catch ( any e ) { - systemOutput( " Could not check extensions via admin: " & e.message, true ); - } - systemOutput( "", true ); - - // Check if DebuggerRegistry exists (Lucee 7.1+) - systemOutput( "Checking DebuggerRegistry (Lucee 7.1+ native debugging API)...", true ); - try { - var DebuggerRegistry = createObject( "java", "lucee.runtime.debug.DebuggerRegistry" ); - systemOutput( " DebuggerRegistry class loaded", true ); - - // Try to get the listener - try { - var listener = DebuggerRegistry.getListener(); - if ( !isNull( listener ) ) { - systemOutput( " Listener registered: " & listener.getClass().getName(), true ); - } else { - systemOutput( " No listener registered (null)", true ); - } - } catch ( any e ) { - systemOutput( " Could not get listener: " & e.message, true ); - } - } catch ( any e ) { - systemOutput( " ERROR: DebuggerRegistry not found - requires Lucee 7.1+", true ); - systemOutput( " " & e.message, true ); - } - systemOutput( "", true ); - - // Simple variable test - systemOutput( "Testing basic execution...", true ); - var x = 1; - var y = 2; - var z = x + y; - systemOutput( " x + y = #z# (execution test passed)", true ); - systemOutput( "", true ); - - systemOutput( "============================================================", true ); - systemOutput( "Extension test complete", true ); - systemOutput( "============================================================", true ); -} - -runTest(); - diff --git a/profiling/test-native-frames-with-agent.bat b/profiling/test-native-frames-with-agent.bat deleted file mode 100644 index 95db53b..0000000 --- a/profiling/test-native-frames-with-agent.bat +++ /dev/null @@ -1,50 +0,0 @@ -@echo off -setlocal - -rem ============================================================ -rem Test native debugger frames with luceedebug agent -rem ============================================================ - -set SCRIPT_RUNNER=D:\work\script-runner -set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling -set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.1.0.7-ALPHA.jar -set LUCEEDEBUG_JAR=D:\work\lucee-extensions\luceedebug\luceedebug\build\libs\luceedebug-2.0.15.jar -set JDWP_PORT=9999 -set DEBUG_PORT=10000 - -rem Check if Lucee JAR exists -if not exist "%LUCEE_JAR%" ( - echo ERROR: Lucee JAR not found at %LUCEE_JAR% - echo Run: cd /d/work/lucee7/loader ^&^& ant fast - exit /b 1 -) - -rem Check if luceedebug JAR exists -if not exist "%LUCEEDEBUG_JAR%" ( - echo ERROR: luceedebug JAR not found at %LUCEEDEBUG_JAR% - echo Run: cd luceedebug ^&^& gradlew shadowJar - exit /b 1 -) - -echo. -echo ============================================================ -echo Testing Native Debugger Frames with luceedebug agent -echo ============================================================ -echo. -echo Lucee JAR: %LUCEE_JAR% -echo luceedebug JAR: %LUCEEDEBUG_JAR% -echo. -echo DAP server will listen on port %DEBUG_PORT% -echo Connect VS Code debugger to test breakpoints -echo. - -rem Enable debugger via env var -set LUCEE_DEBUGGER_ENABLED=true - -rem Enable trace logging to console -set lucee_logging_force_level=trace -set lucee_logging_force_appender=console - -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="test-debugger-frames.cfm" -Djdwp="true" -DjdwpPort="%JDWP_PORT%" -DjavaAgent="%LUCEEDEBUG_JAR%" -DjavaAgentArgs="jdwpHost=localhost,jdwpPort=%JDWP_PORT%,debugHost=0.0.0.0,debugPort=%DEBUG_PORT%,jarPath=%LUCEEDEBUG_JAR%" -DpostCleanup="false" -DpreCleanup="true" - -endlocal diff --git a/profiling/test-native-frames.bat b/profiling/test-native-frames.bat deleted file mode 100644 index 32cfc08..0000000 --- a/profiling/test-native-frames.bat +++ /dev/null @@ -1,32 +0,0 @@ -@echo off -setlocal - -rem ============================================================ -rem Test native debugger frames with local Lucee7 build -rem ============================================================ - -set SCRIPT_RUNNER=D:\work\script-runner -set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling -set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.0.1.97-SNAPSHOT.jar - -rem Check if Lucee JAR exists -if not exist "%LUCEE_JAR%" ( - echo ERROR: Lucee JAR not found at %LUCEE_JAR% - echo Run: cd /d/work/lucee7/loader ^&^& ant fast - exit /b 1 -) - -echo. -echo ============================================================ -echo Testing Native Debugger Frames with local Lucee7 build -echo ============================================================ -echo. -echo Lucee JAR: %LUCEE_JAR% -echo. - -rem Enable debugger via env var -set LUCEE_DEBUGGER_ENABLED=true - -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="test-debugger-frames.cfm" -DpostCleanup="false" -DpreCleanup="true" - -endlocal diff --git a/profiling/test-threads.bat b/profiling/test-threads.bat deleted file mode 100644 index d8571cd..0000000 --- a/profiling/test-threads.bat +++ /dev/null @@ -1,30 +0,0 @@ -@echo off -setlocal - -rem ============================================================ -rem Test DAP threads() command against running luceedebug server -rem ============================================================ - -set SCRIPT_RUNNER=D:\work\script-runner -set PROFILING_DIR=D:\work\lucee-extensions\luceedebug\profiling -set LUCEE_JAR=D:\work\lucee7\loader\target\lucee-7.1.0.7-ALPHA.jar - -rem Check if Lucee JAR exists -if not exist "%LUCEE_JAR%" ( - echo ERROR: Lucee JAR not found at %LUCEE_JAR% - echo Run: cd /d/work/lucee7/loader ^&^& ant fast - exit /b 1 -) - -echo. -echo ============================================================ -echo Testing DAP Threads Command -echo ============================================================ -echo. -echo This test connects to an already-running luceedebug DAP server -echo Make sure your test Tomcat with the extension is running on port 10000 -echo. - -call ant -buildfile "%SCRIPT_RUNNER%" -DluceeJar="%LUCEE_JAR%" -Dwebroot="%PROFILING_DIR%" -Dexecute="test-threads.cfm" -DpostCleanup="false" -DpreCleanup="true" - -endlocal diff --git a/profiling/test-threads.cfm b/profiling/test-threads.cfm deleted file mode 100644 index a065a6f..0000000 --- a/profiling/test-threads.cfm +++ /dev/null @@ -1,40 +0,0 @@ - -// Test DAP threads() command -// Run this from a DIFFERENT Lucee instance than the one being debugged - -systemOutput( "=== DAP Threads Test ===", true ); - -dap = new DapClient( debug=true ); - -try { - dap.connect( "localhost", 10000 ); - - // Initialize - systemOutput( "Sending initialize...", true ); - result = dap.initialize(); - systemOutput( "Initialize response: " & serializeJSON( result ), true ); - - // Get threads - systemOutput( "Sending threads...", true ); - result = dap.threads(); - systemOutput( "Threads response: " & serializeJSON( result ), true ); - - if ( structKeyExists( result, "body" ) && structKeyExists( result.body, "threads" ) ) { - systemOutput( "Found #result.body.threads.len()# threads:", true ); - for ( var t in result.body.threads ) { - systemOutput( " - [#t.id#] #t.name#", true ); - } - } else { - systemOutput( "No threads in response!", true ); - } - - dap.dapDisconnect(); -} catch ( any e ) { - systemOutput( "ERROR: #e.message#", true ); - systemOutput( e.stackTrace, true ); -} finally { - dap.disconnect(); -} - -systemOutput( "=== Test Complete ===", true ); - diff --git a/test/cfml/README.md b/test/cfml/README.md index 825fd99..a5f9a51 100644 --- a/test/cfml/README.md +++ b/test/cfml/README.md @@ -24,7 +24,7 @@ Use your existing Lucee dev server with luceedebug, or use Lucee Express: 1. Download express template from [Lucee Express Templates](https://update.lucee.org/rest/update/provider/expressTemplates) 2. Drop your Lucee JAR in `lib/` -3. Set env vars: `LUCEE_DEBUGGER_SECRET=testing` and `LUCEE_DEBUGGER_PORT=10000` +3. Set env vars: `LUCEE_DAP_SECRET=testing` and `LUCEE_DAP_PORT=10000` 4. Start Tomcat on port 8888 ### Step 2: Run the Tests diff --git a/vscode-client/package.json b/vscode-client/package.json index d804eed..9c4082a 100644 --- a/vscode-client/package.json +++ b/vscode-client/package.json @@ -201,7 +201,7 @@ }, "secret": { "type": "string", - "description": "Secret that must match LUCEE_DEBUGGER_SECRET environment variable on the server." + "description": "Secret that must match LUCEE_DAP_SECRET environment variable on the server." }, "evaluation": { "type": "boolean",