Skip to content

Commit a4e3ab8

Browse files
committed
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.
1 parent 6510cfb commit a4e3ab8

2 files changed

Lines changed: 81 additions & 2 deletions

File tree

lib/debug/server_dap.rb

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,12 +162,12 @@ def dap_setup bytes
162162
},
163163
],
164164
supportsExceptionFilterOptions: true,
165+
supportsExceptionOptions: true,
165166
supportsStepBack: true,
166167
supportsEvaluateForHovers: true,
167168
supportsCompletionsRequest: true,
168169

169170
## Will be supported
170-
# supportsExceptionOptions: true,
171171
# supportsHitConditionalBreakpoints:
172172
# supportsSetVariable: true,
173173
# supportSuspendDebuggee:
@@ -381,7 +381,13 @@ def process_request req
381381
}
382382
}
383383

384-
SESSION.clear_catch_breakpoints 'Exception', 'RuntimeError'
384+
# Catch breakpoints from exceptionOptions are registered under
385+
# arbitrary class names, so previously registered names have to be
386+
# remembered to make setExceptionBreakpoints replace (not accumulate)
387+
# exception breakpoints, as the DAP spec requires.
388+
@exception_option_names ||= []
389+
SESSION.clear_catch_breakpoints 'Exception', 'RuntimeError', *@exception_option_names
390+
@exception_option_names = []
385391

386392
filters = args.fetch('filters').map {|filter_id|
387393
process_filter.call(filter_id)
@@ -391,6 +397,30 @@ def process_request req
391397
process_filter.call(bp_info['filterId'], bp_info['condition'])
392398
}
393399

400+
# DAP standard `exceptionOptions` (capability: supportsExceptionOptions).
401+
# Each ExceptionOptions names specific exception classes via
402+
# ExceptionPathSegment; matching uses the same ancestor class-name
403+
# match as the console `catch` command, so subclasses are caught too.
404+
filters += args.fetch('exceptionOptions', []).map{|opt|
405+
names = opt.fetch('path', []).flat_map{|seg| seg['names'] || []}
406+
407+
if opt.fetch('path', []).any?{|seg| seg['negate']}
408+
{ verified: false, message: 'negated exception path segments are not supported' }
409+
elsif names.empty?
410+
{ verified: false, message: 'no exception class name given' }
411+
elsif opt['breakMode'] == 'never'
412+
{ verified: true }
413+
else
414+
# Ruby's catch breakpoints fire when the exception is raised, so
415+
# 'always', 'unhandled' and 'userUnhandled' all break at raise.
416+
bps = names.map{|name|
417+
@exception_option_names << name
418+
SESSION.add_catch_breakpoint name
419+
}
420+
{ verified: true, message: bps.map(&:inspect).join(', ') }
421+
end
422+
}
423+
394424
send_response req, breakpoints: filters
395425

396426
when 'disconnect'

test/protocol/catch_test.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,53 @@ def test_set_exception_breakpoints_accepts_condition
4444
end
4545
end
4646
end
47+
48+
class CatchExceptionOptionsTest < ProtocolTestCase
49+
PROGRAM = <<~RUBY
50+
1| class MyError < StandardError; end
51+
2| class MySubError < MyError; end
52+
3|
53+
4| def foo
54+
5| raise MySubError, "foo"
55+
6| end
56+
7|
57+
8| foo
58+
RUBY
59+
60+
def test_exception_options_catches_a_specific_exception_class
61+
run_protocol_scenario PROGRAM, cdp: false do
62+
send_dap_request 'setExceptionBreakpoints', filters: [],
63+
exceptionOptions: [{ path: [{ names: ["MyError"] }], breakMode: "always" }]
64+
req_continue
65+
assert_line_num 5
66+
req_terminate_debuggee
67+
end
68+
end
69+
70+
def test_exception_options_with_break_mode_never_does_not_register_a_breakpoint
71+
run_protocol_scenario PROGRAM, cdp: false do
72+
send_dap_request 'setExceptionBreakpoints', filters: [],
73+
exceptionOptions: [{ path: [{ names: ["MyError"] }], breakMode: "never" }]
74+
req_terminate_debuggee
75+
end
76+
end
77+
78+
def test_exception_options_breakpoints_are_replaced_by_the_next_request
79+
run_protocol_scenario PROGRAM, cdp: false do
80+
send_dap_request 'setExceptionBreakpoints', filters: [],
81+
exceptionOptions: [{ path: [{ names: ["MyError"] }], breakMode: "always" }]
82+
send_dap_request 'setExceptionBreakpoints', filters: []
83+
req_terminate_debuggee
84+
end
85+
end
86+
87+
def test_exception_options_reports_unsupported_negated_segments
88+
run_protocol_scenario PROGRAM, cdp: false do
89+
res = send_dap_request 'setExceptionBreakpoints', filters: [],
90+
exceptionOptions: [{ path: [{ negate: true, names: ["MyError"] }], breakMode: "always" }]
91+
assert_equal false, res.dig(:body, :breakpoints, 0, :verified)
92+
req_terminate_debuggee
93+
end
94+
end
95+
end
4796
end

0 commit comments

Comments
 (0)