From a4e3ab8fdb7de9a1bdd0af4e7d73fa4207e03570 Mon Sep 17 00:00:00 2001 From: rira <101010hayakawa@gmail.com> Date: Mon, 6 Jul 2026 11:46:12 +0900 Subject: [PATCH] Support DAP exceptionOptions for catching arbitrary exception classes The console UI can catch arbitrary exception classes (catch MyError), but the DAP implementation only exposed two fixed filters: 'any' (Exception) and 'RuntimeError'. Filter conditions are evaluated in the binding of the raise site and cannot access the raised exception, so they are no substitute for class filtering either. Implement the DAP standard exceptionOptions argument of setExceptionBreakpoints (capability: supportsExceptionOptions), which was already listed under "Will be supported". Class names given via ExceptionPathSegment are registered as catch breakpoints using the same ancestor class-name matching as the console catch command, so subclasses are caught as well. - breakMode 'never' registers nothing. The others all break at raise, since Ruby's catch breakpoints fire when an exception is raised and DAP provides no way for an adapter to tell the client which breakModes it supports. - negate path segments are not supported for now and are reported as unverified. - Class names registered via exceptionOptions are remembered so that the next setExceptionBreakpoints request replaces them, as the DAP spec requires. --- lib/debug/server_dap.rb | 34 +++++++++++++++++++++++-- test/protocol/catch_test.rb | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/lib/debug/server_dap.rb b/lib/debug/server_dap.rb index 907db2d1f..9388a3405 100644 --- a/lib/debug/server_dap.rb +++ b/lib/debug/server_dap.rb @@ -162,12 +162,12 @@ def dap_setup bytes }, ], supportsExceptionFilterOptions: true, + supportsExceptionOptions: true, supportsStepBack: true, supportsEvaluateForHovers: true, supportsCompletionsRequest: true, ## Will be supported - # supportsExceptionOptions: true, # supportsHitConditionalBreakpoints: # supportsSetVariable: true, # supportSuspendDebuggee: @@ -381,7 +381,13 @@ def process_request req } } - SESSION.clear_catch_breakpoints 'Exception', 'RuntimeError' + # Catch breakpoints from exceptionOptions are registered under + # arbitrary class names, so previously registered names have to be + # remembered to make setExceptionBreakpoints replace (not accumulate) + # exception breakpoints, as the DAP spec requires. + @exception_option_names ||= [] + SESSION.clear_catch_breakpoints 'Exception', 'RuntimeError', *@exception_option_names + @exception_option_names = [] filters = args.fetch('filters').map {|filter_id| process_filter.call(filter_id) @@ -391,6 +397,30 @@ def process_request req process_filter.call(bp_info['filterId'], bp_info['condition']) } + # DAP standard `exceptionOptions` (capability: supportsExceptionOptions). + # Each ExceptionOptions names specific exception classes via + # ExceptionPathSegment; matching uses the same ancestor class-name + # match as the console `catch` command, so subclasses are caught too. + filters += args.fetch('exceptionOptions', []).map{|opt| + names = opt.fetch('path', []).flat_map{|seg| seg['names'] || []} + + if opt.fetch('path', []).any?{|seg| seg['negate']} + { verified: false, message: 'negated exception path segments are not supported' } + elsif names.empty? + { verified: false, message: 'no exception class name given' } + elsif opt['breakMode'] == 'never' + { verified: true } + else + # Ruby's catch breakpoints fire when the exception is raised, so + # 'always', 'unhandled' and 'userUnhandled' all break at raise. + bps = names.map{|name| + @exception_option_names << name + SESSION.add_catch_breakpoint name + } + { verified: true, message: bps.map(&:inspect).join(', ') } + end + } + send_response req, breakpoints: filters when 'disconnect' diff --git a/test/protocol/catch_test.rb b/test/protocol/catch_test.rb index b1749b59e..40b325ec9 100644 --- a/test/protocol/catch_test.rb +++ b/test/protocol/catch_test.rb @@ -44,4 +44,53 @@ def test_set_exception_breakpoints_accepts_condition end end end + + class CatchExceptionOptionsTest < ProtocolTestCase + PROGRAM = <<~RUBY + 1| class MyError < StandardError; end + 2| class MySubError < MyError; end + 3| + 4| def foo + 5| raise MySubError, "foo" + 6| end + 7| + 8| foo + RUBY + + def test_exception_options_catches_a_specific_exception_class + run_protocol_scenario PROGRAM, cdp: false do + send_dap_request 'setExceptionBreakpoints', filters: [], + exceptionOptions: [{ path: [{ names: ["MyError"] }], breakMode: "always" }] + req_continue + assert_line_num 5 + req_terminate_debuggee + end + end + + def test_exception_options_with_break_mode_never_does_not_register_a_breakpoint + run_protocol_scenario PROGRAM, cdp: false do + send_dap_request 'setExceptionBreakpoints', filters: [], + exceptionOptions: [{ path: [{ names: ["MyError"] }], breakMode: "never" }] + req_terminate_debuggee + end + end + + def test_exception_options_breakpoints_are_replaced_by_the_next_request + run_protocol_scenario PROGRAM, cdp: false do + send_dap_request 'setExceptionBreakpoints', filters: [], + exceptionOptions: [{ path: [{ names: ["MyError"] }], breakMode: "always" }] + send_dap_request 'setExceptionBreakpoints', filters: [] + req_terminate_debuggee + end + end + + def test_exception_options_reports_unsupported_negated_segments + run_protocol_scenario PROGRAM, cdp: false do + res = send_dap_request 'setExceptionBreakpoints', filters: [], + exceptionOptions: [{ path: [{ negate: true, names: ["MyError"] }], breakMode: "always" }] + assert_equal false, res.dig(:body, :breakpoints, 0, :verified) + req_terminate_debuggee + end + end + end end