Repository: ruby/debug (Ruby)

Issue:
VS Code breakpoints skipped after Rails reloads related code
**Your environment**

* `ruby -v`: `3.1.2`
* `rdbg -v`: `1.7.0`

**Describe the bug**

Breakpoints added via VS Code seems to be lost after surrounding code is modified and reloaded by Rails.

For example, if we set a breakpoint at

```rb
def index
  @posts = Post.all # set the breakpoint at this line
end
```

And then we insert a new line to the method:

```rb
def index
  puts "foo"
  @posts = Post.all # VS Code would move the breakpoint here after update
end
```

The breakpoint would not be triggered anymore.


**To Reproduce**

1. Clone [this repo](https://github.com/st0012/debug-vscode-issue).
1. Run `$ bundle install`.
1. Run `$ bundle exec bin/rails db:migrate RAILS_ENV=development`.
1. Run `$ bundle exec rdbg -O -n -c -- bundle exec bin/rails s` to run the server with debugger in server mode.
1. Press `F5` to attach VS Code to the debugger.
1. Go to `app/controllers/posts_controller.rb` and place an VS Code breakpoint at line `6`.

    ```rb
    def index
      @posts = Post.all # set the breakpoint at this line
    end
    ```
1. Verify the breakpoint can be hit:
    1. In browser, visit http://localhost:3000.
    1. See the breakpoint being hit.
    1. Press `F5` again to continue the program.
1. Add `puts "foo"` at `app/controllers/posts_controller.rb:6`

    ```rb
    def index
      puts "foo"
      @posts = Post.all
    end
    ```
1. Refresh the page in browser.

**Expected behavior**

VS Code breakpoints can still trigger after the file is updated.

**Additional context**

- When updating other methods, like adding something to the `new` method (10 lines away), the breakpoint still works. So I suspect it's related to the breakpoint's iseq being outdated?

- From DAP server logs, it looks like the debugger does receive updated `setBreakpoints` request:

```
#25986:[>] {"command":"setBreakpoints","arguments":{"source":{"path":"/Users/hung-wulo/src/github.com/st0012/debug-issue/app/controllers/posts_controller.rb","name":"posts_controller.rb","sourceReference":0},"lines":[6],"breakpoints":[{"line":6}],"sourceModified":true},"type":"request","seq":13}
#25986:[<] {"type":"response","command":"setBreakpoints","request_seq":13,"success":true,"message":"Success","body":{"breakpoints":[{"verified":true}]},"seq":977}
```

But the breakpoint wasn't actually added/activated.



Discussion: I think it's related to `LineBreakpoint`'s `@pending` state: 

After the first breakpoint hit, the later breakpoint would has `@iseq` like 

```
<RubyVM::InstructionSequence:index@/Users/hung-wulo/src/github.com/st0012/debug-issue/app/controllers/posts_controller.rb:5>
```

And this means its [`@pending` state would be `false`](https://github.com/ruby/debug/blob/f42e9c6539641f2fd76f9e2d14e77f0a86808378/lib/debug/breakpoint.rb#L163).

So when the [`:script_compiled` event is triggered](https://github.com/ruby/debug/blob/f42e9c6539641f2fd76f9e2d14e77f0a86808378/lib/debug/session.rb#L136)
1. `bps_pending_until_load?` would also be `false`
1. Thus `ThreadClient.current.on_load` wouldn't be triggered for the updated code
1. Which could have [reloaded related `LineBreakpoint`s](https://github.com/ruby/debug/blob/f42e9c6539641f2fd76f9e2d14e77f0a86808378/lib/debug/session.rb#L1737-L1746)

But I'm not sure which part is the root cause of the issue:

- Should the later breakpoint's `@iseq` still be `nil`?
- Should `bps_pending_until_load?` also take the bps' file path into consideration?
- Or it could be something else?

### Workaround

As a quick workaround, changing `bps_pending_until_load?` to just do `@bps.any?` seems to work.
Nothing to add other than to comment I have also been experiencing this bug. For the breakpoints to start registering after changing the code I have restart the server and attach the debug session again. 

A fix for this would be great!
```ruby
# repro code
File.write("l.rb", <<RUBY)
  # l.rb
  def foo # 2
    p 1   # 3
    p 2
  end
RUBY
load 'l.rb'
foo

debugger do: 'b l.rb:3'

p :loeaded

File.write("l.rb", <<RUBY)
  # l.rb
  def foo
    p 0
    p 1
    p 2
  end
RUBY

load 'l.rb'

foo
```
Sorry it is not a correct repro-code.
It needs VSCode interaction.
All right, I repro it.

```ruby
File.write("l.rb", <<RUBY)
  # l.rb
  def foo
    p 1
  end
RUBY

load 'l.rb'
p :loeaded
debugger # set braekpoint at "l.rb:3" here

File.write("l.rb", <<RUBY)
  def foo
    p 0
    p 1
  end
RUBY

load 'l.rb'

foo
```

and next `foo` doesn't stop.
```ruby
File.write("l.rb", <<RUBY)
  def foo
    p 1
  end
RUBY

load 'l.rb'
p :loeaded

debugger do: 'b l.rb:2'

foo

File.write("l.rb", <<RUBY)
  def foo
    p 0
    p 1
  end
RUBY

load 'l.rb'

foo
```

This is repro-code.



Fix (patch):
diff --git a/lib/debug/breakpoint.rb b/lib/debug/breakpoint.rb
index 65161f919..13e9c428b 100644
--- a/lib/debug/breakpoint.rb
+++ b/lib/debug/breakpoint.rb
@@ -101,10 +101,6 @@ def skip_path?(path)
     def generate_label(name)
       colorize(" BP - #{name} ", [:YELLOW, :BOLD, :REVERSE])
     end
-
-    def pending_until_load?
-      false
-    end
   end
 
   if RUBY_VERSION.to_f <= 2.7
@@ -163,10 +159,6 @@ def initialize path, line, cond: nil, oneshot: false, hook_call: true, command:
       @pending = !@iseq
     end
 
-    def pending_until_load?
-      @pending
-    end
-
     def setup
       return unless @type
 
@@ -207,6 +199,8 @@ def activate iseq, event, line
       if @pending && !@oneshot
         DEBUGGER__.info "#{self} is activated."
       end
+
+      @pending = false
     end
 
     def activate_exact iseq, events, line
@@ -299,6 +293,10 @@ def to_s
     def inspect
       "<#{self.class.name} #{self.to_s}>"
     end
+
+    def path_is? path
+      DEBUGGER__.compare_path(@path, path)
+    end
   end
 
   class CatchBreakpoint < Breakpoint
diff --git a/lib/debug/server_dap.rb b/lib/debug/server_dap.rb
index 4c8d7d8ad..168c0743a 100644
--- a/lib/debug/server_dap.rb
+++ b/lib/debug/server_dap.rb
@@ -314,7 +314,6 @@ def process
         when 'setBreakpoints'
           req_path = args.dig('source', 'path')
           path = UI_DAP.local_to_remote_path(req_path)
-
           if path
             SESSION.clear_line_breakpoints path
 
diff --git a/lib/debug/session.rb b/lib/debug/session.rb
index ffd333a37..b2ee5e50a 100644
--- a/lib/debug/session.rb
+++ b/lib/debug/session.rb
@@ -133,13 +133,11 @@ def initialize
       @commands = {}
       @unsafe_context = false
 
-      has_keep_script_lines = RubyVM.respond_to? :keep_script_lines
+      @has_keep_script_lines = RubyVM.respond_to? :keep_script_lines
 
       @tp_load_script = TracePoint.new(:script_compiled){|tp|
-        if !has_keep_script_lines || bps_pending_until_load?
-          eval_script = tp.eval_script unless has_keep_script_lines
-          ThreadClient.current.on_load tp.instruction_sequence, eval_script
-        end
+        eval_script = tp.eval_script unless @has_keep_script_lines
+        ThreadClient.current.on_load tp.instruction_sequence, eval_script
       }
       @tp_load_script.enable
 
@@ -1334,10 +1332,6 @@ def ask msg, default = 'Y'
 
     # breakpoint management
 
-    def bps_pending_until_load?
-      @bps.any?{|key, bp| bp.pending_until_load?}
-    end
-
     def iterate_bps
       deleted_bps = []
       i = 0
@@ -1513,7 +1507,7 @@ def clear_breakpoints(&condition)
     def clear_line_breakpoints path
       path = resolve_path(path)
       clear_breakpoints do |k, bp|
-        bp.is_a?(LineBreakpoint) && DEBUGGER__.compare_path(k.first, path)
+        bp.is_a?(LineBreakpoint) && bp.path_is?(path)
       end
     rescue Errno::ENOENT
       # just ignore
@@ -1737,25 +1731,26 @@ def on_load iseq, src
       file_path, reloaded = @sr.add(iseq, src)
       @ui.event :load, file_path, reloaded
 
-      pending_line_breakpoints = @bps.find_all do |key, bp|
-        LineBreakpoint === bp && !bp.iseq
-      end
-
-      pending_line_breakpoints.each do |_key, bp|
-        if DEBUGGER__.compare_path(bp.path, (iseq.absolute_path || iseq.path))
-          bp.try_activate iseq
-        end
-      end
-
-      if reloaded
-        @bps.find_all do |key, bp|
-          LineBreakpoint === bp && DEBUGGER__.compare_path(bp.path, file_path)
+      # check breakpoints
+      if file_path
+        @bps.find_all do |_key, bp|
+          LineBreakpoint === bp && bp.path_is?(file_path)
         end.each do |_key, bp|
-          @bps.delete bp.key # to allow duplicate
-          if nbp = LineBreakpoint.copy(bp, iseq)
-            add_bp nbp
+          if !bp.iseq
+            bp.try_activate iseq
+          elsif reloaded
+            @bps.delete bp.key # to allow duplicate
+            if nbp = LineBreakpoint.copy(bp, iseq)
+              add_bp nbp
+            end
           end
         end
+      else # !file_path => file_path is not existing
+        @bps.find_all do |_key, bp|
+          LineBreakpoint === bp && !bp.iseq && DEBUGGER__.compare_path(bp.path, (iseq.absolute_path || iseq.path))
+        end.each do |_key, bp|
+          bp.try_activate iseq
+        end
       end
     end
 
diff --git a/lib/debug/source_repository.rb b/lib/debug/source_repository.rb
index b2ef28409..ddcfd6e93 100644
--- a/lib/debug/source_repository.rb
+++ b/lib/debug/source_repository.rb
@@ -34,7 +34,7 @@ def initialize
       end
 
       def add iseq, src
-        # do nothing
+        # only manage loaded file names
         if (path = (iseq.absolute_path || iseq.path)) && File.exist?(path)
           if @loaded_file_map.has_key? path
             return path, true # reloaded