Skip to content

F# computation expression line breakpoints don't fire (closure class methods not searched) #221

@bryancostanich

Description

@bryancostanich

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 ceTests

DAP 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions