Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<Compile Include="Program.fs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Expecto" Version="10.2.3" />
</ItemGroup>

</Project>
32 changes: 32 additions & 0 deletions repro/fsharp-ce-breakpoints/FSharpCeBreakpointRepro/Program.fs
Original file line number Diff line number Diff line change
@@ -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 ---

[<EntryPoint>]
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
82 changes: 82 additions & 0 deletions repro/fsharp-ce-breakpoints/README.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 64 additions & 0 deletions repro/fsharp-ce-breakpoints/test-cli.sh
Original file line number Diff line number Diff line change
@@ -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 ==="
Loading