From c7383b42837a92a174e64a70e5b3516d8ee42b0c Mon Sep 17 00:00:00 2001 From: bryan costanich Date: Fri, 3 Apr 2026 10:26:42 -0700 Subject: [PATCH 1/2] Add F# CE breakpoint repro project Minimal reproduction case for Samsung/netcoredbg#221: line breakpoints inside F# computation expression blocks don't fire because GetMethodTokensByLineNumber() doesn't search closure class methods. Includes automated DAP and CLI test scripts plus PDB analysis. --- .../FSharpCeBreakpointRepro.fsproj | 16 ++ .../FSharpCeBreakpointRepro/Program.fs | 32 +++ repro/fsharp-ce-breakpoints/README.md | 82 ++++++ repro/fsharp-ce-breakpoints/test-cli.sh | 64 +++++ repro/fsharp-ce-breakpoints/test-dap.py | 239 ++++++++++++++++++ 5 files changed, 433 insertions(+) create mode 100644 repro/fsharp-ce-breakpoints/FSharpCeBreakpointRepro/FSharpCeBreakpointRepro.fsproj create mode 100644 repro/fsharp-ce-breakpoints/FSharpCeBreakpointRepro/Program.fs create mode 100644 repro/fsharp-ce-breakpoints/README.md create mode 100755 repro/fsharp-ce-breakpoints/test-cli.sh create mode 100755 repro/fsharp-ce-breakpoints/test-dap.py diff --git a/repro/fsharp-ce-breakpoints/FSharpCeBreakpointRepro/FSharpCeBreakpointRepro.fsproj b/repro/fsharp-ce-breakpoints/FSharpCeBreakpointRepro/FSharpCeBreakpointRepro.fsproj new file mode 100644 index 00000000..52d28f71 --- /dev/null +++ b/repro/fsharp-ce-breakpoints/FSharpCeBreakpointRepro/FSharpCeBreakpointRepro.fsproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + + + + + + + + + + + diff --git a/repro/fsharp-ce-breakpoints/FSharpCeBreakpointRepro/Program.fs b/repro/fsharp-ce-breakpoints/FSharpCeBreakpointRepro/Program.fs new file mode 100644 index 00000000..e10d30c9 --- /dev/null +++ b/repro/fsharp-ce-breakpoints/FSharpCeBreakpointRepro/Program.fs @@ -0,0 +1,32 @@ +module FSharpCeBreakpointRepro + +open Expecto + +// --- Regular function (breakpoints work in both CLI and DAP) --- + +let regularFunction () = + let x = 42 // Line 8: SET BREAKPOINT HERE (regular) + let y = x + 1 // Line 9 + printfn "Regular: x=%d y=%d" x y // Line 10 + y + +// --- Expecto CE test block (breakpoints work in CLI, fail in DAP) --- + +let ceTests = + test "ce breakpoint test" { + let a = 100 // Line 17: SET BREAKPOINT HERE (CE) + let b = a + 1 // Line 18 + printfn "CE: a=%d b=%d" a b // Line 19 + Expect.equal b 101 "b should be 101" // Line 20 + } + +// --- Entry point --- + +[] +let main argv = + // Call the regular function so its breakpoint is reachable + let result = regularFunction () // Line 28 + printfn "regularFunction returned %d" result + + // Run the Expecto test + runTestsWithCLIArgs [] argv ceTests diff --git a/repro/fsharp-ce-breakpoints/README.md b/repro/fsharp-ce-breakpoints/README.md new file mode 100644 index 00000000..2499fd99 --- /dev/null +++ b/repro/fsharp-ce-breakpoints/README.md @@ -0,0 +1,82 @@ +# F# Computation Expression Breakpoint Bug — netcoredbg + +## Bug + +Line breakpoints inside F# computation expression (CE) blocks don't fire. The breakpoint silently slides to the nearest non-closure sequence point (typically the module initializer). + +This affects all F# CE-heavy frameworks: Expecto, FAKE, Saturn, Giraffe, etc. + +## Root cause + +The F# compiler transforms CE bodies into closure classes. For example: + +```fsharp +let ceTests = + test "ce breakpoint test" { + let a = 100 // Line 17 — user wants breakpoint here + ... + } +``` + +The PDB contains correct sequence points for the closure method `ceTests@17.Invoke` at lines 17-20. However, netcoredbg's `GetMethodTokensByLineNumber()` only searches the outer method's range, misses the closure class entirely, and slides the breakpoint to the nearest available sequence point in the static constructor (line 15). + +## Reproduction + +### Prerequisites + +- .NET 8 SDK +- netcoredbg (any recent version; tested with 3.1.3) + +### Build + +```bash +cd FSharpCeBreakpointRepro +dotnet build -c Debug +``` + +### Test — DAP mode (shows the bug) + +```bash +python3 test-dap.py +``` + +Expected output: +``` +Line 8 (regular function): HIT +Line 17 (CE block body): MISSED +Line 15 (CE module init): HIT (bp slid from line 17 to 15) +``` + +### Test — CLI mode (same bug, previously thought to work) + +```bash +bash test-cli.sh +``` + +Shows the same behavior: `break Program.fs:17` resolves to line 15 (`.cctor()`), not the CE body. + +### PDB verification + +The closure class has correct sequence points: + +``` +=== ceTests@17.Invoke (0x0600000D) === + IL_0000 Program.fs:17 (col 9-20) + IL_0003 Program.fs:18 (col 9-22) + IL_0007 Program.fs:19 (col 9-36) + IL_0025 Program.fs:20 (col 9-45) +``` + +The data is in the PDB — the debugger's method token enumeration just doesn't find it. + +## Environment + +- macOS arm64 (Darwin 25.3.0) +- .NET SDK 8.0.201 +- F# compiler: included in .NET SDK 8.0 +- netcoredbg 3.1.3 (built from source) +- Expecto 10.2.3 + +## Fix direction + +`GetMethodTokensByLineNumber()` in `modules_sources.cpp` needs to search closure/nested class methods when the target line falls within a closure's PDB sequence point range. The closure classes are present in the metadata — they have entries in `IMetaDataImport::EnumNestedClasses()` — but the current enumeration doesn't include them in the method range search. diff --git a/repro/fsharp-ce-breakpoints/test-cli.sh b/repro/fsharp-ce-breakpoints/test-cli.sh new file mode 100755 index 00000000..24d2e738 --- /dev/null +++ b/repro/fsharp-ce-breakpoints/test-cli.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Test breakpoints in netcoredbg CLI mode. +# Strategy: function-break on main to stop after module load, +# then set line breakpoints (which now resolve against loaded modules). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DLL="$SCRIPT_DIR/FSharpCeBreakpointRepro/bin/Debug/net8.0/FSharpCeBreakpointRepro.dll" +NETCOREDBG="${NETCOREDBG:-$HOME/.local/bin/netcoredbg/netcoredbg}" + +if [[ ! -f "$DLL" ]]; then + echo "Build first: dotnet build -c Debug" + exit 1 +fi + +echo "=== netcoredbg CLI mode breakpoint test ===" +echo "Strategy: break on main, run, then set line breakpoints after module load." +echo "" + +# Use a FIFO for controlled input +FIFO=$(mktemp -u) +mkfifo "$FIFO" + +"$NETCOREDBG" --interpreter=cli -- dotnet exec "$DLL" < "$FIFO" 2>&1 & +DBG_PID=$! +exec 3>"$FIFO" + +send() { + echo ">>> $1" >&2 + echo "$1" >&3 + sleep 1 +} + +sleep 1 + +# Set function breakpoint on main, then run +send "break FSharpCeBreakpointRepro.main" +send "run" +sleep 3 + +# Now modules are loaded — set line breakpoints +send "break Program.fs:8" +send "break Program.fs:17" + +# Continue — should hit line 8 +send "continue" +sleep 1 + +# Continue — should hit line 17 (CE block) +send "continue" +sleep 1 + +# Let it finish +send "continue" +sleep 2 + +send "quit" +sleep 0.5 + +exec 3>&- +rm -f "$FIFO" +wait $DBG_PID 2>/dev/null || true +echo "" +echo "=== Done ===" diff --git a/repro/fsharp-ce-breakpoints/test-dap.py b/repro/fsharp-ce-breakpoints/test-dap.py new file mode 100755 index 00000000..b3106b1d --- /dev/null +++ b/repro/fsharp-ce-breakpoints/test-dap.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +Test breakpoints in netcoredbg DAP (vscode) mode. + +Sends DAP JSON messages to netcoredbg and checks whether breakpoints +on regular function lines and CE block lines actually fire. + +Expected result (demonstrating the bug): + - Line 8 (regular function): breakpoint fires OK + - Line 17 (CE block): breakpoint slides to cctor or never fires +""" + +import json +import subprocess +import sys +import os +import re +import select +import threading + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +DLL = os.path.join(SCRIPT_DIR, "FSharpCeBreakpointRepro", "bin", "Debug", "net8.0", "FSharpCeBreakpointRepro.dll") +SOURCE = os.path.join(SCRIPT_DIR, "FSharpCeBreakpointRepro", "Program.fs") +NETCOREDBG = os.environ.get("NETCOREDBG", os.path.expanduser("~/.local/bin/netcoredbg/netcoredbg")) + +class DAPClient: + """Buffered DAP protocol reader/writer.""" + + def __init__(self, proc): + self.proc = proc + self.fd = proc.stdout.fileno() + self.buf = b"" + self._seq = 0 + + def _raw_read(self, timeout_secs=15): + """Non-blocking read from stdout fd.""" + ready, _, _ = select.select([self.fd], [], [], timeout_secs) + if not ready: + raise TimeoutError(f"Timeout, buf={self.buf[:80]!r}") + chunk = os.read(self.fd, 65536) + if not chunk: + raise EOFError("netcoredbg closed stdout") + return chunk + + def _ensure(self, n, timeout=15): + """Ensure at least n bytes in buffer.""" + while len(self.buf) < n: + self.buf += self._raw_read(timeout) + + def _read_until(self, marker, timeout=15): + while marker not in self.buf: + self.buf += self._raw_read(timeout) + idx = self.buf.index(marker) + len(marker) + data, self.buf = self.buf[:idx], self.buf[idx:] + return data + + def recv(self): + """Read one DAP message.""" + header = self._read_until(b"\r\n\r\n") + m = re.search(rb"Content-Length:\s*(\d+)", header) + if not m: + raise ValueError(f"Bad header: {header!r}") + length = int(m.group(1)) + self._ensure(length) + body, self.buf = self.buf[:length], self.buf[length:] + msg = json.loads(body) + # msg received + return msg + + def send(self, command, arguments=None): + self._seq += 1 + msg = { + "type": "request", + "seq": self._seq, + "command": command, + "arguments": arguments or {} + } + body = json.dumps(msg).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + self.proc.stdin.write(header + body) + self.proc.stdin.flush() + return self._seq + + def wait_for(self, msg_type, command=None, event=None, timeout_msgs=100): + """Read messages until matching one found.""" + for _ in range(timeout_msgs): + try: + msg = self.recv() + except (TimeoutError, EOFError): + return None + t = msg.get("type") + if t == msg_type: + if command and msg.get("command") == command: + return msg + if event and msg.get("event") == event: + return msg + if not command and not event: + return msg + return None + + +def main(): + print(f"DLL: {DLL}") + print(f"Source: {SOURCE}") + print(f"netcoredbg: {NETCOREDBG}") + print() + + if not os.path.exists(DLL): + print("ERROR: Build first with 'dotnet build -c Debug'") + sys.exit(1) + + proc = subprocess.Popen( + [NETCOREDBG, "--interpreter=vscode"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + dap = DAPClient(proc) + + try: + # 1. Initialize + dap.send("initialize", { + "clientID": "repro-test", + "adapterID": "coreclr", + "linesStartAt1": True, + "columnsStartAt1": True, + "pathFormat": "path", + }) + resp = dap.wait_for("response", command="initialize") + print(f"1. initialize: {'OK' if resp and resp.get('success') else 'FAIL'}") + + # 2. Launch with stopAtEntry + dap.send("launch", { + "name": "repro", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": ["exec", DLL], + "cwd": os.path.dirname(DLL), + "stopAtEntry": True, + "justMyCode": False, + }) + resp = dap.wait_for("response", command="launch") + print(f"2. launch: {'OK' if resp and resp.get('success') else 'FAIL'}") + + # 3. Set breakpoints + dap.send("setBreakpoints", { + "source": {"name": "Program.fs", "path": SOURCE}, + "breakpoints": [ + {"line": 8}, # regular function + {"line": 17}, # CE block + ], + "lines": [8, 17], + "sourceModified": False, + }) + resp = dap.wait_for("response", command="setBreakpoints") + bps = resp.get("body", {}).get("breakpoints", []) if resp else [] + print("3. setBreakpoints response:") + for bp in bps: + print(f" line {bp.get('line', '?'):>3}: verified={bp.get('verified')}, " + f"id={bp.get('id')}, msg={bp.get('message', '')}") + + # 4. configurationDone + dap.send("configurationDone") + resp = dap.wait_for("response", command="configurationDone") + print(f"4. configurationDone: {'OK' if resp and resp.get('success') else 'FAIL'}") + + # 5. Wait for stopped (entry) + stopped = dap.wait_for("event", event="stopped") + reason = stopped.get("body", {}).get("reason", "?") if stopped else "none" + print(f"5. stopped (entry): reason={reason}") + + # 6. Continue and track hits + hit_lines = [] + for attempt in range(5): + dap.send("continue", {"threadId": 1}) + dap.wait_for("response", command="continue") + + stopped = dap.wait_for("event", event="stopped", timeout_msgs=300) + if not stopped: + print(f" continue #{attempt+1}: no stopped event (program likely exited)") + break + + body = stopped.get("body", {}) + reason = body.get("reason", "?") + tid = body.get("threadId", 1) + if reason == "breakpoint": + # Try to get threads first + dap.send("threads") + threads_resp = dap.wait_for("response", command="threads") + if threads_resp: + threads = threads_resp.get("body", {}).get("threads", []) + if threads: + tid = threads[0].get("id", tid) + + dap.send("stackTrace", {"threadId": tid, "startFrame": 0, "levels": 5}) + st = dap.wait_for("response", command="stackTrace") + frames = st.get("body", {}).get("stackFrames", []) if st else [] + if frames: + line = frames[0].get("line", "?") + name = frames[0].get("name", "?") + hit_lines.append(line) + src = frames[0].get("source", {}).get("name", "?") + print(f" continue #{attempt+1}: BREAKPOINT at {src}:{line} in {name}") + else: + print(f" continue #{attempt+1}: breakpoint hit (no stack frames, tid={tid})") + print(f" raw response: {json.dumps(st, indent=2)[:300]}") + elif reason in ("exited", "terminated"): + print(f" continue #{attempt+1}: {reason}") + break + else: + print(f" continue #{attempt+1}: {reason} (tid={tid})") + + # Summary + print() + print("=" * 60) + print("SUMMARY") + print("=" * 60) + print(f" Line 8 (regular function): {'HIT' if 8 in hit_lines else 'MISSED'}") + print(f" Line 17 (CE block body): {'HIT' if 17 in hit_lines else 'MISSED'}") + if 15 in hit_lines: + print(f" Line 15 (CE module init): HIT (bp slid from line 17 to 15)") + print() + if 17 not in hit_lines: + print("BUG CONFIRMED: Line 17 breakpoint inside CE block does not fire.") + print("The PDB has sequence points in ceTests@17.Invoke for lines 17-20,") + print("but netcoredbg doesn't resolve the bp into the closure class.") + else: + print("CE breakpoint fired! Bug may be fixed in this version.") + + # Disconnect + dap.send("disconnect", {"terminateDebuggee": True}) + + finally: + proc.terminate() + proc.wait(timeout=5) + +if __name__ == "__main__": + main() From ec6edf80c28a148790a29cf8f771f045c038a810 Mon Sep 17 00:00:00 2001 From: bryan costanich Date: Fri, 3 Apr 2026 11:43:48 -0700 Subject: [PATCH 2/2] Fix F# CE breakpoints: resolve to nested closure when sourceLine is inside When a nested method (e.g., F# computation expression closure) is fully contained within an outer method's sequence point range, the breakpoint resolver unconditionally picked the outer method. This caused breakpoints on lines inside F# CE blocks like `test "name" { ... }` to slide to the module initializer (.cctor) instead of the closure body. The fix checks whether sourceLine falls within the nested method's range. If so, the breakpoint is set on the nested method. If sourceLine is before the nested range (e.g., C# await with inline lambda), the outer method is used as before. Also replaces assertions in NestedInto() that crash on F# compiler- generated methods sharing start/end positions, returning false instead. Repro: repro/fsharp-ce-breakpoints/ Issue: #221 --- repro/fsharp-ce-breakpoints/test-dap.py | 13 +++-------- src/managed/SymbolReader.cs | 30 ++++++++++++++++--------- src/metadata/modules_sources.cpp | 2 +- src/metadata/modules_sources.h | 8 +++++-- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/repro/fsharp-ce-breakpoints/test-dap.py b/repro/fsharp-ce-breakpoints/test-dap.py index b3106b1d..07481888 100755 --- a/repro/fsharp-ce-breakpoints/test-dap.py +++ b/repro/fsharp-ce-breakpoints/test-dap.py @@ -113,7 +113,7 @@ def main(): [NETCOREDBG, "--interpreter=vscode"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stderr=subprocess.DEVNULL, ) dap = DAPClient(proc) @@ -138,7 +138,7 @@ def main(): "args": ["exec", DLL], "cwd": os.path.dirname(DLL), "stopAtEntry": True, - "justMyCode": False, + "justMyCode": True, }) resp = dap.wait_for("response", command="launch") print(f"2. launch: {'OK' if resp and resp.get('success') else 'FAIL'}") @@ -185,14 +185,7 @@ def main(): reason = body.get("reason", "?") tid = body.get("threadId", 1) if reason == "breakpoint": - # Try to get threads first - dap.send("threads") - threads_resp = dap.wait_for("response", command="threads") - if threads_resp: - threads = threads_resp.get("body", {}).get("threads", []) - if threads: - tid = threads[0].get("id", tid) - + # Use threadId from the stopped event directly dap.send("stackTrace", {"threadId": tid, "startFrame": 0, "levels": 5}) st = dap.wait_for("response", command="stackTrace") frames = st.get("body", {}).get("stackFrames", []) if st else [] diff --git a/src/managed/SymbolReader.cs b/src/managed/SymbolReader.cs index 4440c54b..6553dd92 100644 --- a/src/managed/SymbolReader.cs +++ b/src/managed/SymbolReader.cs @@ -800,19 +800,27 @@ SequencePoint SequencePointForSourceLine(Position reqPos, ref MetadataReader rea if (nestedToken != 0) { - // Check if nestedToken is within range of current_p. Example - - // await Parallel.ForEachAsync(userHandlers, parallelOptions, async (uri, token) => <- breakpoint at this line - // { - // await new HttpClient().GetAsync("https://google.com"); - // }); - // nesetedToken here is the annonymous async func, and having a breakpoing at the 1st line should - // break on the outer call. SequencePoint nested_start_p = SequencePointForSourceLine(Position.First, ref reader, nestedToken); SequencePoint nested_end_p = SequencePointForSourceLine(Position.Last, ref reader, nestedToken); - if ((nested_start_p.StartLine > current_p.StartLine || (nested_start_p.StartLine == current_p.StartLine && nested_start_p.StartColumn > current_p.StartColumn)) && - (nested_end_p.EndLine < current_p.EndLine || (nested_end_p.EndLine == current_p.EndLine && nested_end_p.EndColumn < current_p.EndColumn )) - ) { - list.Add(new resolved_bp_t(current_p.StartLine, current_p.EndLine, current_p.Offset, methodToken)); + bool nestedFullyContained = + (nested_start_p.StartLine > current_p.StartLine || (nested_start_p.StartLine == current_p.StartLine && nested_start_p.StartColumn > current_p.StartColumn)) && + (nested_end_p.EndLine < current_p.EndLine || (nested_end_p.EndLine == current_p.EndLine && nested_end_p.EndColumn < current_p.EndColumn)); + + if (nestedFullyContained) + { + // The nested method is fully contained within the outer method's SP range. + // If sourceLine falls within the nested method's own range, the user wants + // to break inside the nested body (e.g., F# CE block, lambda body). + // If sourceLine is before the nested method starts, the user wants the + // outer call (e.g., C# await with inline lambda on same line). + if (sourceLine >= nested_start_p.StartLine) + { + list.Add(new resolved_bp_t(nested_start_p.StartLine, nested_start_p.EndLine, nested_start_p.Offset, nestedToken)); + } + else + { + list.Add(new resolved_bp_t(current_p.StartLine, current_p.EndLine, current_p.Offset, methodToken)); + } break; } diff --git a/src/metadata/modules_sources.cpp b/src/metadata/modules_sources.cpp index 82cf91de..788e73da 100644 --- a/src/metadata/modules_sources.cpp +++ b/src/metadata/modules_sources.cpp @@ -201,7 +201,7 @@ namespace } Tokens.emplace_back(result->methodDef); } - + return !!result; } diff --git a/src/metadata/modules_sources.h b/src/metadata/modules_sources.h index 6d59e6d5..6f0d0feb 100644 --- a/src/metadata/modules_sources.h +++ b/src/metadata/modules_sources.h @@ -65,8 +65,12 @@ struct method_data_t bool NestedInto(const method_data_t &other) const { - assert(startLine != other.startLine || startColumn != other.startColumn); - assert(endLine != other.endLine || endColumn != other.endColumn); + // F# compiler-generated methods (closures, CE blocks) can share start/end positions. + // Return false for identical positions rather than asserting. + if (startLine == other.startLine && startColumn == other.startColumn) + return false; + if (endLine == other.endLine && endColumn == other.endColumn) + return false; return (startLine > other.startLine || (startLine == other.startLine && startColumn > other.startColumn)) && (endLine < other.endLine || (endLine == other.endLine && endColumn < other.endColumn));