Repository: ruby/debug (Ruby)

Issue:
Breakpoint is not enabled on reloaded file
Now reloaded file removes the breakpoint.

```ruby
# a. rb

load './b.rb'
foo

load './b.rb'
foo
```

```ruby
# b.rb
def foo
  p 1
end
```

```
[master]$ ../../exe/rdbg a.rb
[1, 6] in a.rb
     1|
=>   2| load './b.rb'
     3| foo
     4|
     5| load './b.rb'
     6| foo
=>#0    <main> at a.rb:2
(rdbg) b b.rb:3    # break command
#0  BP - Line (pending)  /mnt/c/ko1/src/rb/ruby-debug/tmp/v44/b.rb:3
(rdbg) c    # continue command
DEBUGGER:  BP - Line  /mnt/c/ko1/src/rb/ruby-debug/tmp/v44/b.rb:3 (call) is activated.
[1, 4] in /mnt/c/ko1/src/rb/ruby-debug/tmp/v44/b.rb
     1|
     2| def foo
=>   3|   p 1
     4| end
=>#0    Object#foo at /mnt/c/ko1/src/rb/ruby-debug/tmp/v44/b.rb:3
  #1    <main> at a.rb:3

Stop by #0  BP - Line  /mnt/c/ko1/src/rb/ruby-debug/tmp/v44/b.rb:3 (call)
(rdbg) c    # continue command
1                                                                                                                      <== DON'T STOP AFTER RELOADED
1
```

https://github.com/ruby/vscode-rdbg/issues/44



Discussion:
Questions:

1. Should we remain the breakpoints? The file can be modified when the next loading and the lines can be strange.
2. Should we keep the BP pending information?
For 1, I think we should keep the breakpoints. Users specified the file and line number and that's what they have in mind through out the debugging session. Even if the location ended up being in a funny state, it's still easy for them to figure that out. On the contrary, losing a breakpoint after file reload is not what I'd expect as a user and it limits what I can do in a debugging session.

For 2, I haven't felt it helpful but I'm ok if we keep it for now.
Let's try to leave on same lines even if the file was changed.



Patch:
diff --git a/lib/debug/breakpoint.rb b/lib/debug/breakpoint.rb
index 8bcc66568..fb3361378 100644
--- a/lib/debug/breakpoint.rb
+++ b/lib/debug/breakpoint.rb
@@ -131,9 +131,17 @@ def enable
   end
 
   class LineBreakpoint < Breakpoint
-    attr_reader :path, :line, :iseq
+    attr_reader :path, :line, :iseq, :cond, :oneshot, :hook_call, :command
 
-    def initialize path, line, cond: nil, oneshot: false, hook_call: true, command: nil
+    def self.copy bp, root_iseq
+      nbp = LineBreakpoint.new bp.path, bp.line,
+                               cond: bp.cond, oneshot: bp.oneshot, hook_call: bp.hook_call,
+                               command: bp.command, skip_activate: true
+      nbp.try_activate root_iseq
+      nbp
+    end
+
+    def initialize path, line, cond: nil, oneshot: false, hook_call: true, command: nil, skip_activate: false
       @line = line
       @oneshot = oneshot
       @hook_call = hook_call
@@ -146,7 +154,7 @@ def initialize path, line, cond: nil, oneshot: false, hook_call: true, command:
 
       super(cond, command, path)
 
-      try_activate
+      try_activate unless skip_activate
       @pending = !@iseq
     end
 
@@ -216,42 +224,56 @@ def duplicable?
 
     NearestISeq = Struct.new(:iseq, :line, :events)
 
-    def try_activate
-      nearest = nil # NearestISeq
-
-      ObjectSpace.each_iseq{|iseq|
-        if DEBUGGER__.compare_path((iseq.absolute_path || iseq.path), self.path) &&
-            iseq.first_lineno <= self.line &&
-            iseq.type != :ensure # ensure iseq is copied (duplicated)
+    def iterate_iseq root_iseq
+      if root_iseq
+        is = [root_iseq]
+        while iseq = is.pop
+          yield iseq
+          iseq.each_child do |child_iseq|
+            is << child_iseq
+          end
+        end
+      else
+        ObjectSpace.each_iseq do |iseq|
+          if DEBUGGER__.compare_path((iseq.absolute_path || iseq.path), self.path) &&
+             iseq.first_lineno <= self.line &&
+             iseq.type != :ensure # ensure iseq is copied (duplicated)
+            yield iseq
+          end
+        end
+      end
+    end
 
-          iseq.traceable_lines_norec(line_events = {})
-          lines = line_events.keys.sort
+    def try_activate root_iseq = nil
+      nearest = nil # NearestISeq
+      iterate_iseq root_iseq do |iseq|
+        iseq.traceable_lines_norec(line_events = {})
+        lines = line_events.keys.sort
 
-          if !lines.empty? && lines.last >= line
-            nline = lines.bsearch{|l| line <= l}
-            events = line_events[nline]
+        if !lines.empty? && lines.last >= line
+          nline = lines.bsearch{|l| line <= l}
+          events = line_events[nline]
 
-            next if events == [:RUBY_EVENT_B_CALL]
+          next if events == [:RUBY_EVENT_B_CALL]
 
-            if @hook_call &&
-               events.include?(:RUBY_EVENT_CALL) &&
-               self.line == iseq.first_lineno
-              nline = iseq.first_lineno
-            end
+          if @hook_call &&
+            events.include?(:RUBY_EVENT_CALL) &&
+            self.line == iseq.first_lineno
+            nline = iseq.first_lineno
+          end
 
-            if !nearest || ((line - nline).abs < (line - nearest.line).abs)
-              nearest = NearestISeq.new(iseq, nline, events)
-            else
-              if @hook_call && nearest.iseq.first_lineno <= iseq.first_lineno
-                if (nearest.line > line && !nearest.events.include?(:RUBY_EVENT_CALL)) ||
-                   (events.include?(:RUBY_EVENT_CALL))
-                  nearest = NearestISeq.new(iseq, nline, events)
-                end
+          if !nearest || ((line - nline).abs < (line - nearest.line).abs)
+            nearest = NearestISeq.new(iseq, nline, events)
+          else
+            if @hook_call && nearest.iseq.first_lineno <= iseq.first_lineno
+              if (nearest.line > line && !nearest.events.include?(:RUBY_EVENT_CALL)) ||
+                (events.include?(:RUBY_EVENT_CALL))
+                nearest = NearestISeq.new(iseq, nline, events)
               end
             end
           end
         end
-      }
+      end
 
       if nearest
         activate_exact nearest.iseq, nearest.events, nearest.line
diff --git a/lib/debug/server_dap.rb b/lib/debug/server_dap.rb
index cbb10fe40..cc6952bab 100644
--- a/lib/debug/server_dap.rb
+++ b/lib/debug/server_dap.rb
@@ -388,6 +388,16 @@ def puts result
 
     def event type, *args
       case type
+      when :load
+        file_path, reloaded = *args
+
+        if file_path
+          send_event 'loadedSource',
+                     reason: (reloaded ? :changed : :new),
+                     source: {
+                       path: file_path,
+                     }
+        end
       when :suspend_bp
         _i, bp, tid = *args
         if bp.kind_of?(CatchBreakpoint)
diff --git a/lib/debug/session.rb b/lib/debug/session.rb
index 9daf46da8..37ce3abac 100644
--- a/lib/debug/session.rb
+++ b/lib/debug/session.rb
@@ -230,7 +230,6 @@ def process_event evt
       when :load
         iseq, src = ev_args
         on_load iseq, src
-        @ui.event :load
         request_tc :continue
 
       when :trace
@@ -1590,7 +1589,9 @@ def in_subsession?
 
     def on_load iseq, src
       DEBUGGER__.info "Load #{iseq.absolute_path || iseq.path}"
-      @sr.add 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
@@ -1598,7 +1599,18 @@ def on_load iseq, src
 
       pending_line_breakpoints.each do |_key, bp|
         if DEBUGGER__.compare_path(bp.path, (iseq.absolute_path || iseq.path))
-          bp.try_activate
+          bp.try_activate iseq
+        end
+      end
+
+      if reloaded
+        @bps.find_all do |key, bp|
+          LineBreakpoint === bp && DEBUGGER__.compare_path(bp.path, file_path)
+        end.each do |_key, bp|
+          @bps.delete bp.key # to allow duplicate
+          if nbp = LineBreakpoint.copy(bp, iseq)
+            add_bp nbp
+          end
         end
       end
     end
diff --git a/lib/debug/source_repository.rb b/lib/debug/source_repository.rb
index 0cc2855db..76142dd1e 100644
--- a/lib/debug/source_repository.rb
+++ b/lib/debug/source_repository.rb
@@ -14,10 +14,19 @@ class SourceRepository
       def initialize
         # cache
         @cmap = ObjectSpace::WeakMap.new
+        @loaded_file_map = {} # path => nil
       end
 
       def add iseq, src
         # do nothing
+        if (path = (iseq.absolute_path || iseq.path)) && File.exist?(path)
+          if @loaded_file_map.has_key? path
+            return path, true # reloaded
+          else
+            @loaded_file_map[path] = path
+            return path, false
+          end
+        end
       end
 
       def get iseq
@@ -55,10 +64,14 @@ def initialize
 
       def add iseq, src
         if (path = (iseq.absolute_path || iseq.path)) && File.exist?(path)
+          reloaded = @files.has_key? path
           add_path path
+          return path, reloaded
         elsif src
           add_iseq iseq, src
         end
+
+        nil
       end
 
       private def all_iseq iseq, rs = []


Test patch:
diff --git a/test/console/break_test.rb b/test/console/break_test.rb
index 69a2bc58a..c9ecf2669 100644
--- a/test/console/break_test.rb
+++ b/test/console/break_test.rb
@@ -748,4 +748,44 @@ def test_the_path_option_supersede_skip_path_config
       end
     end
   end
+
+  class BreakAtLinesReloadTest < ConsoleTestCase
+    def extra_file
+      <<~RUBY
+      def foo  # 1
+        a = 10 # 2
+        b = 20 # 3
+        c = 30 # 4
+      end
+      RUBY
+    end
+
+    def program path
+      <<~RUBY
+      1| load #{path.dump}
+      2| foo()
+      3| load #{path.dump}
+      4| foo()
+      RUBY
+    end
+
+    def test_break_on_realoded_file
+      with_extra_tempfile do |extra_file|
+        debug_code(program(extra_file.path)) do
+          type "break #{extra_file.path}:2 do: p :xyzzy"
+          type "break #{extra_file.path}:3"
+
+          type 'c'
+          assert_line_num 3
+          assert_line_text(/xyzzy/)
+
+          type 'c'
+          assert_line_num 3         # should stop at reloaded file
+          assert_line_text(/xyzzy/) # should do at line 2
+
+          type 'c'
+        end
+      end
+    end
+  end
 end