Skip to content

Commit 867e857

Browse files
committed
(GH-100) Add a debug server
This commit adds the code necessary to start a Puppet debug session which is compliant with VS Code's debug adapter protocol. Instead this is over TCP instead of STDIN/OUT. This commit also updates the gulpfile so that this code is added during the extension packaging process.
1 parent 05f8b5b commit 867e857

File tree

11 files changed

+2924
-0
lines changed

11 files changed

+2924
-0
lines changed

client/gulpfile.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ gulp.task('clean', function () {
1616
gulp.task('copy_language_server', function () {
1717
return gulp.src(['../server/lib/**/*',
1818
'../server/vendor/**/*',
19+
'../server/puppet-debugserver',
1920
'../server/puppet-languageserver'
2021
], { base: '../server'})
2122
.pipe(gulp.dest('./vendor/languageserver'));

server/lib/debugserver/debug_protocol.rb

Lines changed: 1308 additions & 0 deletions
Large diffs are not rendered by default.

server/lib/puppet-debugserver.rb

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
require 'debugserver/debug_protocol'
2+
require 'puppet-vscode'
3+
4+
%w[json_handler message_router hooks puppet_debug_session debug_hook_handlers puppet_debug_breakpoints puppet_monkey_patches].each do |lib|
5+
begin
6+
require "puppet-debugserver/#{lib}"
7+
rescue LoadError
8+
require File.expand_path(File.join(File.dirname(__FILE__), 'puppet-debugserver', lib))
9+
end
10+
end
11+
12+
require 'optparse'
13+
require 'logger'
14+
15+
module PuppetDebugServer
16+
class CommandLineParser
17+
def self.parse(options)
18+
# Set defaults here
19+
args = {
20+
port: 8082,
21+
ipaddress: '127.0.0.1',
22+
stop_on_client_exit: true,
23+
connection_timeout: 10,
24+
debug: nil,
25+
}
26+
27+
opt_parser = OptionParser.new do |opts|
28+
opts.banner = 'Usage: puppet-debugserver.rb [options]'
29+
30+
opts.on('-pPORT', '--port=PORT', "TCP Port to listen on. Default is #{args[:port]}") do |port|
31+
args[:port] = port.to_i
32+
end
33+
34+
opts.on('-ipADDRESS', '--ip=ADDRESS', "IP Address to listen on (0.0.0.0 for all interfaces). Default is #{args[:ipaddress]}") do |ipaddress|
35+
args[:ipaddress] = ipaddress
36+
end
37+
38+
opts.on('-tTIMEOUT', '--timeout=TIMEOUT', "Stop the Debug Server if a client does not connection within TIMEOUT seconds. A value of zero will not timeout. Default is #{args[:connection_timeout]} seconds") do |timeout|
39+
args[:connection_timeout] = timeout.to_i
40+
end
41+
42+
opts.on('--debug=DEBUG', "Output debug information. Either specify a filename or 'STDOUT'. Default is no debug output") do |debug|
43+
args[:debug] = debug
44+
end
45+
46+
opts.on('-h', '--help', 'Prints this help') do
47+
puts opts
48+
exit
49+
end
50+
51+
opts.on('-v', '--version', 'Prints the Debug Server version') do
52+
puts PuppetVSCode.version
53+
exit
54+
end
55+
end
56+
57+
opt_parser.parse!(options.dup)
58+
args
59+
end
60+
end
61+
62+
def self.log_message(severity, message)
63+
PuppetVSCode::log_message(severity, message)
64+
end
65+
66+
def self.init_puppet(options)
67+
PuppetVSCode::init_logging(options)
68+
log_message(:info, "Debug Server is v#{PuppetVSCode.version}")
69+
70+
true
71+
end
72+
73+
def self.rpc_server(options)
74+
log_message(:info, 'Starting RPC Server...')
75+
76+
server = PuppetVSCode::SimpleTCPServer.new
77+
78+
options[:servicename] = 'DEBUG SERVER'
79+
80+
server.add_service(options[:ipaddress], options[:port])
81+
trap('INT') { server.stop }
82+
server.start(PuppetDebugServer::MessageRouter, options, 2)
83+
84+
log_message(:info, 'Debug Server exited.')
85+
end
86+
end
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
module PuppetDebugServer
2+
module PuppetDebugSession
3+
@hook_handler = nil
4+
5+
def self.hooks
6+
if @hook_handler.nil?
7+
@hook_handler = PuppetDebugServer::Hooks.new
8+
9+
@hook_handler.add_hook(:hook_before_apply_exit, :debug_session) { |args| PuppetDebugServer::PuppetDebugSession::on_hook_before_apply_exit(args) }
10+
@hook_handler.add_hook(:hook_breakpoint, :debug_session) { |args| PuppetDebugServer::PuppetDebugSession::hook_breakpoint(args) }
11+
@hook_handler.add_hook(:hook_step_breakpoint, :debug_session) { |args| PuppetDebugServer::PuppetDebugSession::hook_step_breakpoint(args) }
12+
@hook_handler.add_hook(:hook_function_breakpoint, :debug_session) { |args| PuppetDebugServer::PuppetDebugSession::hook_function_breakpoint(args) }
13+
@hook_handler.add_hook(:hook_before_compile, :debug_session) { |args| PuppetDebugServer::PuppetDebugSession::hook_before_compile(args) }
14+
@hook_handler.add_hook(:hook_exception, :debug_session) { |args| PuppetDebugServer::PuppetDebugSession::hook_exception(args) }
15+
@hook_handler.add_hook(:hook_log_message, :debug_session) { |args| PuppetDebugServer::PuppetDebugSession::hook_log_message(args) }
16+
@hook_handler.add_hook(:hook_after_parser_function_reset, :debug_session) { |args| PuppetDebugServer::PuppetDebugSession::hook_after_parser_function_reset(args) }
17+
@hook_handler.add_hook(:hook_before_pops_evaluate, :debug_session) { |args| PuppetDebugServer::PuppetDebugSession::hook_before_pops_evaluate(args) }
18+
@hook_handler.add_hook(:hook_after_pops_evaluate, :debug_session) { |args| PuppetDebugServer::PuppetDebugSession::hook_after_pops_evaluate(args) }
19+
end
20+
@hook_handler
21+
end
22+
23+
def self.hook_before_pops_evaluate(args)
24+
@session_pops_eval_depth = @session_pops_eval_depth + 1
25+
target = args[1]
26+
# Ignore this if there is no positioning information available
27+
return unless target.is_a?(Puppet::Pops::Model::Positioned)
28+
# Even if it's positioned, it can still contain invalid information. Ignore it if
29+
# it's missing required information. This can happen when evaluting strings (e.g. watches from VSCode)
30+
# i.e. not a file on disk
31+
return if target.file.nil? || target.file.empty?
32+
33+
# Break if we hit a specific puppet function
34+
if target._pcore_type.simple_name == 'CallNamedFunctionExpression'
35+
# TODO Do we really need to break on a function called breakpoint?
36+
if target.functor_expr.value == 'breakpoint'
37+
# Re-raise the hook as a breakpoint
38+
PuppetDebugServer::PuppetDebugSession.hooks.exec_hook(:hook_function_breakpoint, [target.functor_expr.value, target._pcore_type.name] +args)
39+
return
40+
else
41+
func_names = PuppetDebugServer::PuppetDebugSession.function_breakpoints
42+
func_names.each do |func|
43+
next unless func['name'] == target.functor_expr.value
44+
# Re-raise the hook as a breakpoint
45+
PuppetDebugServer::PuppetDebugSession.hooks.exec_hook(:hook_function_breakpoint, [target.functor_expr.value, target._pcore_type.name] + args)
46+
return
47+
end
48+
end
49+
end
50+
51+
unless target.length == 0
52+
excluded_classes = ['BlockExpression','HostClassDefinition']
53+
file_path = target.file
54+
breakpoints = PuppetDebugServer::PuppetDebugSession.source_breakpoints(file_path)
55+
56+
#target._pcore_type.simple_name
57+
# TODO should check if it's an object we don't care aount
58+
59+
unless excluded_classes.include?(target._pcore_type.simple_name) || breakpoints.nil? || breakpoints.empty?
60+
# Calculate the start and end lines of the target
61+
target_start_line = target.line
62+
target_end_line = target.locator.line_for_offset(target.offset + target.length)
63+
64+
breakpoints.each do |bp|
65+
bp_line = bp['line']
66+
# TODO Hit and conditional BreakPoints?
67+
if bp_line >= target_start_line && bp_line <= target_end_line
68+
# Re-raise the hook as a breakpoint
69+
PuppetDebugServer::PuppetDebugSession.hooks.exec_hook(:hook_breakpoint, [target._pcore_type.name, ''] + args)
70+
#require 'pry'; binding.pry
71+
return
72+
end
73+
end
74+
end
75+
end
76+
77+
# Break if we are stepping
78+
case PuppetDebugServer::PuppetDebugSession.run_mode
79+
when :stepin
80+
# Stepping-in is basically break on everything
81+
# Re-raise the hook as a step breakpoint
82+
PuppetDebugServer::PuppetDebugSession.hooks.exec_hook(:hook_step_breakpoint, [target._pcore_type.name, ''] + args)
83+
return
84+
when :next
85+
# Next will break on anything at this Pop depth or shallower
86+
# Re-raise the hook as a step breakpoint
87+
run_options = PuppetDebugServer::PuppetDebugSession.run_mode_options
88+
if !run_options[:pops_depth_level].nil? && @session_pops_eval_depth <= run_options[:pops_depth_level]
89+
PuppetDebugServer::PuppetDebugSession.hooks.exec_hook(:hook_step_breakpoint, [target._pcore_type.name, ''] + args)
90+
return
91+
end
92+
when :stepout
93+
# Stepping-Out will break on anything shallower than this Pop depth
94+
# Re-raise the hook as a step breakpoint
95+
run_options = PuppetDebugServer::PuppetDebugSession.run_mode_options
96+
if !run_options[:pops_depth_level].nil? && @session_pops_eval_depth < run_options[:pops_depth_level]
97+
PuppetDebugServer::PuppetDebugSession.hooks.exec_hook(:hook_step_breakpoint, [target._pcore_type.name, ''] + args)
98+
return
99+
end
100+
end
101+
end
102+
def self.hook_after_pops_evaluate(args)
103+
@session_pops_eval_depth = @session_pops_eval_depth - 1
104+
target = args[1]
105+
return unless target.is_a?(Puppet::Pops::Model::Positioned)
106+
end
107+
108+
def self.hook_after_parser_function_reset(args)
109+
func_object = args[0]
110+
111+
# TODO Do we really need to break on a function called breakpoint?
112+
func_object.newfunction(:breakpoint, :type => :rvalue, :arity => -1, :doc => "Breakpoint Function") do |arguments|
113+
# This function is just a place holder. It gets interpretted at the pops_evaluate hooks but the function
114+
# itself still needs to exist though.
115+
end
116+
end
117+
118+
def self.on_hook_before_apply_exit(args)
119+
option = args[0]
120+
121+
PuppetDebugServer::PuppetDebugSession.connection.send_exited_event(option)
122+
PuppetDebugServer::PuppetDebugSession.connection.send_output_event({
123+
'category' => 'console',
124+
'output' => "puppet exited with #{option}",
125+
})
126+
end
127+
128+
def self.hook_breakpoint(args)
129+
process_breakpoint_hook('breakpoint', args)
130+
end
131+
132+
def self.hook_function_breakpoint(args)
133+
process_breakpoint_hook('function breakpoint', args)
134+
end
135+
136+
def self.hook_step_breakpoint(args)
137+
process_breakpoint_hook('step', args)
138+
end
139+
140+
def self.process_breakpoint_hook(reason, args)
141+
# If the debug session is paused, can't raise a new breakpoint
142+
return if PuppetDebugServer::PuppetDebugSession.session_paused?
143+
break_display_text = args[0]
144+
break_description = args[1]
145+
146+
scope_object = nil
147+
pops_target_object = nil
148+
pops_depth_level = nil
149+
150+
# Check if the breakpoint came from the Pops::Evaluator
151+
if args[2].is_a?(Puppet::Pops::Evaluator::EvaluatorImpl)
152+
pops_target_object = args[3]
153+
scope_object = args[4]
154+
pops_depth_level = @session_pops_eval_depth
155+
end
156+
157+
break_description = break_display_text if break_description.empty?
158+
PuppetDebugServer::PuppetDebugSession.raise_and_wait_stopped_event(reason, break_display_text, break_description, {
159+
:pops_target => pops_target_object,
160+
:scope => scope_object,
161+
:pops_depth_level => pops_depth_level,
162+
:puppet_stacktrace => Puppet::Pops::PuppetStack.stacktrace
163+
})
164+
end
165+
166+
def self.hook_before_compile(args)
167+
PuppetDebugServer::PuppetDebugSession.session_compiler = args[0]
168+
169+
# Spin-wait for the configurationDone message from the client before we continue compilation
170+
begin
171+
sleep(0.5)
172+
end while !PuppetDebugServer::PuppetDebugSession.client_completed_configuration?
173+
end
174+
175+
def self.hook_exception(args)
176+
# If the debug session is paused, can't raise a new exception
177+
return if PuppetDebugServer::PuppetDebugSession.session_paused?
178+
179+
error_detail = args[0]
180+
181+
PuppetDebugServer::PuppetDebugSession.raise_and_wait_stopped_event(
182+
'exception', 'Compilation Exception', error_detail.basic_message, {
183+
:session_exception => error_detail,
184+
:puppet_stacktrace => Puppet::Pops::PuppetStack.stacktrace_from_backtrace(error_detail)
185+
})
186+
end
187+
188+
def self.hook_log_message(args)
189+
return if self.suppress_log_messages
190+
msg = args[0]
191+
str = msg.respond_to?(:multiline) ? msg.multiline : msg.to_s
192+
str = msg.source == 'Puppet' ? str : "#{msg.source}: #{str}"
193+
194+
level = msg.level.to_s.capitalize
195+
196+
category = 'stderr'
197+
category = 'stdout' if msg.level == :notice || msg.level == :info || msg.level == :debug
198+
199+
PuppetDebugServer::PuppetDebugSession.connection.send_output_event({
200+
'category' => category,
201+
'output' => "#{level}: #{str}\n"
202+
})
203+
end
204+
end
205+
end

0 commit comments

Comments
 (0)