-
Notifications
You must be signed in to change notification settings - Fork 141
F# computation expression line breakpoints don't fire (closure class methods not searched) #221
Description
Summary
Line breakpoints inside F# computation expression (CE) blocks never fire. The breakpoint silently slides to the nearest non-closure sequence point (typically the module static constructor). This affects both DAP (setBreakpoints) and CLI (break) modes identically.
This impacts all F# projects using CE-heavy frameworks: Expecto, FAKE, Saturn, Giraffe, etc.
Reproduction
Minimal repro project (zero framework dependencies beyond Expecto): https://github.com/bryancostanich/netcoredbg/tree/main/repro/fsharp-ce-breakpoints
// Program.fs
module FSharpCeBreakpointRepro
open Expecto
let regularFunction () =
let x = 42 // Line 8: breakpoint works
let y = x + 1
printfn "Regular: x=%d y=%d" x y
y
let ceTests =
test "ce breakpoint test" {
let a = 100 // Line 17: breakpoint DOES NOT fire
let b = a + 1
printfn "CE: a=%d b=%d" a b
Expect.equal b 101 "b should be 101"
}
[<EntryPoint>]
let main argv =
let result = regularFunction ()
printfn "regularFunction returned %d" result
runTestsWithCLIArgs [] argv ceTestsDAP mode test
$ python3 test-dap.py
1. initialize: OK
2. launch: OK
3. setBreakpoints response:
line 8: verified=False, id=1, msg=The breakpoint is pending and will be resolved when debugging starts.
line 17: verified=False, id=2, msg=The breakpoint is pending and will be resolved when debugging starts.
4. configurationDone: OK
5. stopped (entry): reason=entry
continue #1: BREAKPOINT at Program.fs:15 in <StartupCode$FSharpCeBreakpointRepro>.$FSharpCeBreakpointRepro..cctor()
continue #2: BREAKPOINT at Program.fs:8 in FSharpCeBreakpointRepro.regularFunction()
continue #3: no stopped event (program likely exited)
SUMMARY
Line 8 (regular function): HIT
Line 17 (CE block body): MISSED
Line 15 (CE module init): HIT (bp slid from line 17 to 15)
CLI mode test
Same behavior — break Program.fs:17 resolves to line 15 (.cctor()), not the CE body:
stopped, reason: breakpoint 3 hit, ..., frame={<StartupCode$FSharpCeBreakpointRepro>.$FSharpCeBreakpointRepro..cctor() at .../Program.fs:15}
Root cause analysis
The F# compiler transforms CE bodies into closure classes. The PDB contains correct sequence points for the closure method:
=== ceTests@17.Invoke (token 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 issue is in GetMethodTokensByLineNumber() (modules_sources.cpp). When resolving a breakpoint for line 17, it searches the method ranges loaded from the PDB but only considers the outer/containing methods. The F# closure class ceTests@17 is a nested type with its own Invoke method, and its method token is never included in the search. The breakpoint slides to the nearest available sequence point in the static constructor (line 15, the let ceTests = binding).
The closure class methods are present in IMetaDataImport::EnumNestedClasses() and have correct PDB data — GetMethodTokensByLineNumber() just doesn't search them.
Environment
- macOS arm64 (Darwin 25.3.0)
- .NET SDK 8.0.201 / F# compiler 14.0.100.0
- netcoredbg 3.1.3-1 (commit 8b8b222)
- Expecto 10.2.3
Suggested fix
Extend GetMethodTokensByLineNumber() (or the method range loading in GetPdbMethodsRanges()) to include closure/nested class methods in the search. The nested types are already enumerable via IMetaDataImport — they just need to be included in the methodsData used for line-to-method resolution.
I'm working on a fix in my fork and will submit a PR when ready.