Class: Pwsh::Manager
- Inherits:
-
Object
- Object
- Pwsh::Manager
- Defined in:
- lib/pwsh.rb
Overview
Create an instance of a PowerShell host and manage execution of PowerShell code inside that host.
Constant Summary collapse
- @@instances =
We actually want this to be a class variable.
{}
Instance Attribute Summary collapse
-
#powershell_arguments ⇒ Object
readonly
Returns the value of attribute powershell_arguments.
-
#powershell_command ⇒ Object
readonly
Returns the value of attribute powershell_command.
Class Method Summary collapse
-
.default_options ⇒ Hash
Returns a set of default options for instantiating a manager.
-
.instance(cmd, args, options = {}) ⇒ Object
Return an instance of the manager if one already exists for the specified options or instantiate a new one if needed.
-
.instance_key(cmd, args, options) ⇒ Object
The unique key for a given manager as determined by the full path to the executable, the arguments to pass to the executable, and the options specified for the manager; this enables the code to reuse an existing manager if the same path, arguments, and options are specified.
-
.instances ⇒ Hash
Return the list of currently instantiated instances of the PowerShell Manager.
-
.powershell_args ⇒ Array[String]
Default arguments for running Windows PowerShell via the manager.
-
.powershell_path ⇒ String
The path to Windows PowerShell on the system.
-
.ps_output_to_hash!(bytes) ⇒ Hash
Takes a given input byte-stream from PowerShell, length-prefixed, and reads the key-value pairs from that output until all the information is retrieved.
-
.pwsh_args ⇒ Array[String]
Default arguments for running PowerShell 6+ via the manager.
-
.pwsh_path(additional_paths = []) ⇒ String
Retrieves the absolute path to pwsh.
-
.pwsh_supported? ⇒ Bool
Determine whether or not the manager is supported on the machine for PowerShell 6+.
-
.read_length_prefixed_string!(bytes) ⇒ String
The manager sends a 4-byte integer representing the number of bytes to read for the incoming string.
-
.readable?(stream, timeout = 0.5) ⇒ Bool
Return whether or not a particular stream is valid and readable.
-
.stream_valid?(stream) ⇒ Bool
When a stream has been closed by handle, but Ruby still has a file descriptor for it, it can be tricky to detemine that it’s actually dead.
-
.template_path ⇒ String
Return the path to the bootstrap template.
-
.win32console_enabled? ⇒ Bool
Determine whether or not the Win32 Console is enabled.
-
.windows_powershell_supported? ⇒ Bool
Determine whether or not the manager is supported on the machine for Windows PowerShell.
Instance Method Summary collapse
-
#alive? ⇒ Bool
Return whether or not the manager is running, usable, and the I/O streams remain open.
-
#drain_pipe_until_signaled(pipe, signal) ⇒ Array
Read from a specified pipe for as long as the signal is locked and the pipe is readable.
-
#exec_read_result(powershell_code) ⇒ Array
Executes PowerShell code over the PowerShell manager and returns the results.
-
#execute(powershell_code, timeout_ms = nil, working_dir = nil, environment_variables = []) ⇒ Hash
Run specified powershell code via the manager.
-
#exit ⇒ Object
Tear down the instance of the manager, shutting down the pipe and process.
-
#initialize(cmd, args = [], options = {}) ⇒ Object
constructor
Instantiate a new instance of the PowerShell Manager.
-
#length_prefixed_string(data) ⇒ Object
Take a given string and prefix it with a 4-byte length and encode for sending to the PowerShell manager.
-
#make_ps_code(powershell_code, timeout_ms = nil, working_dir = nil, environment_variables = []) ⇒ String
Return the block of code to be run by the manager with appropriate settings.
-
#pipe_command(command) ⇒ Object
This is the command that the ruby process will send to the PowerShell process and utilizes a 1 byte command identifier 0 - Exit 1 - Execute.
-
#read_from_pipe(pipe, timeout = 0.1) {|String| ... } ⇒ Object
Read output from the PowerShell manager process via the pipe.
-
#read_streams ⇒ Array
Open threads and pipes to read stdout and stderr from the PowerShell manager, then continue to read data from the manager until either all data is returned or an error interrupts the normal flow, then return that data.
-
#write_pipe(input) ⇒ Object
Writes binary-encoded data to the PowerShell manager process via the pipe.
Constructor Details
#initialize(cmd, args = [], options = {}) ⇒ Object
Instantiate a new instance of the PowerShell Manager
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/pwsh.rb', line 106 def initialize(cmd, args = [], = {}) @usable = true @powershell_command = cmd @powershell_arguments = args warn "Bad configuration for ENV['lib']=#{ENV['lib']} - invalid path" if Pwsh::Util.invalid_directories?(ENV['lib']) if Pwsh::Util.on_windows? # Named pipes under Windows will automatically be mounted in \\.\pipe\... # https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Windows.cs#L34 named_pipe_name = "#{SecureRandom.uuid}PsHost" # This named pipe path is Windows specific. pipe_path = "\\\\.\\pipe\\#{named_pipe_name}" else require 'tmpdir' # .Net implements named pipes under Linux etc. as Unix Sockets in the filesystem # Paths that are rooted are not munged within C# Core. # https://github.com/dotnet/corefx/blob/94e9d02ad70b2224d012ac4a66eaa1f913ae4f29/src/System.IO.Pipes/src/System/IO/Pipes/PipeStream.Unix.cs#L49-L60 # https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Unix.cs#L44 # https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Unix.cs#L298-L299 named_pipe_name = File.join(Dir.tmpdir, "#{SecureRandom.uuid}PsHost") pipe_path = named_pipe_name end pipe_timeout = [:pipe_timeout] || self.class.[:pipe_timeout] debug = [:debug] || self.class.[:debug] native_cmd = Pwsh::Util.on_windows? ? "\"#{cmd}\"" : cmd ps_args = args + ['-File', self.class.template_path, "\"#{named_pipe_name}\""] ps_args << '"-EmitDebugOutput"' if debug # @stderr should never be written to as PowerShell host redirects output stdin, @stdout, @stderr, @ps_process = Open3.popen3("#{native_cmd} #{ps_args.join(' ')}") stdin.close # TODO: Log a debug for "#{Time.now} #{cmd} is running as pid: #{@ps_process[:pid]}" # Wait up to 180 seconds in 0.2 second intervals to be able to open the pipe. # If the pipe_timeout is ever specified as less than the sleep interval it will # never try to connect to a pipe and error out as if a timeout occurred. sleep_interval = 0.2 (pipe_timeout / sleep_interval).to_int.times do begin # rubocop:disable Style/RedundantBegin @pipe = if Pwsh::Util.on_windows? # Pipe is opened in binary mode and must always <- always what?? File.open(pipe_path, 'r+b') else UNIXSocket.new(pipe_path) end break rescue StandardError sleep sleep_interval end end if @pipe.nil? # Tear down and kill the process if unable to connect to the pipe; failure to do so # results in zombie processes being left after a caller run. We discovered that # closing @ps_process via .kill instead of using this method actually kills the # watcher and leaves an orphaned process behind. Failing to close stdout and stderr # also leaves clutter behind, so explicitly close those too. @stdout.close unless @stdout.closed? @stderr.close unless @stderr.closed? Process.kill('KILL', @ps_process[:pid]) if @ps_process.alive? raise "Failure waiting for PowerShell process #{@ps_process[:pid]} to start pipe server" end # TODO: Log a debug for "#{Time.now} PowerShell initialization complete for pid: #{@ps_process[:pid]}" at_exit { exit } end |
Instance Attribute Details
#powershell_arguments ⇒ Object (readonly)
Returns the value of attribute powershell_arguments.
20 21 22 |
# File 'lib/pwsh.rb', line 20 def powershell_arguments @powershell_arguments end |
#powershell_command ⇒ Object (readonly)
Returns the value of attribute powershell_command.
20 21 22 |
# File 'lib/pwsh.rb', line 20 def powershell_command @powershell_command end |
Class Method Details
.default_options ⇒ Hash
Returns a set of default options for instantiating a manager
34 35 36 37 38 39 |
# File 'lib/pwsh.rb', line 34 def self. { debug: false, pipe_timeout: 30 } end |
.instance(cmd, args, options = {}) ⇒ Object
Return an instance of the manager if one already exists for the specified options or instantiate a new one if needed
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/pwsh.rb', line 48 def self.instance(cmd, args, = {}) = .merge!() key = instance_key(cmd, args, ) manager = @@instances[key] if manager.nil? || !manager.alive? # ignore any errors trying to tear down this unusable instance begin manager.exit unless manager.nil? # rubocop:disable Style/SafeNavigation rescue StandardError nil end @@instances[key] = Manager.new(cmd, args, ) end @@instances[key] end |
.instance_key(cmd, args, options) ⇒ Object
The unique key for a given manager as determined by the full path to the executable, the arguments to pass to the executable, and the options specified for the manager; this enables the code to reuse an existing manager if the same path, arguments, and options are specified.
@return Unique string representing the manager instance.
407 408 409 |
# File 'lib/pwsh.rb', line 407 def self.instance_key(cmd, args, ) cmd + args.join(' ') + .to_s end |
.instances ⇒ Hash
Return the list of currently instantiated instances of the PowerShell Manager
27 28 29 |
# File 'lib/pwsh.rb', line 27 def self.instances @@instances end |
.powershell_args ⇒ Array[String]
Default arguments for running Windows PowerShell via the manager
327 328 329 330 331 332 |
# File 'lib/pwsh.rb', line 327 def self.powershell_args ps_args = ['-NoProfile', '-NonInteractive', '-NoLogo', '-ExecutionPolicy', 'Bypass'] ps_args << '-Command' unless windows_powershell_supported? ps_args end |
.powershell_path ⇒ String
The path to Windows PowerShell on the system
337 338 339 340 341 342 343 344 345 |
# File 'lib/pwsh.rb', line 337 def self.powershell_path if File.exist?("#{ENV.fetch('SYSTEMROOT', nil)}\\sysnative\\WindowsPowershell\\v1.0\\powershell.exe") "#{ENV.fetch('SYSTEMROOT', nil)}\\sysnative\\WindowsPowershell\\v1.0\\powershell.exe" elsif File.exist?("#{ENV.fetch('SYSTEMROOT', nil)}\\system32\\WindowsPowershell\\v1.0\\powershell.exe") "#{ENV.fetch('SYSTEMROOT', nil)}\\system32\\WindowsPowershell\\v1.0\\powershell.exe" else 'powershell.exe' end end |
.ps_output_to_hash!(bytes) ⇒ Hash
Takes a given input byte-stream from PowerShell, length-prefixed, and reads the key-value pairs from that output until all the information is retrieved. Mutates the given bytes.
458 459 460 461 462 463 464 |
# File 'lib/pwsh.rb', line 458 def self.ps_output_to_hash!(bytes) hash = {} hash[read_length_prefixed_string!(bytes).to_sym] = read_length_prefixed_string!(bytes) until bytes.empty? hash end |
.pwsh_args ⇒ Array[String]
Default arguments for running PowerShell 6+ via the manager
397 398 399 |
# File 'lib/pwsh.rb', line 397 def self.pwsh_args ['-NoProfile', '-NonInteractive', '-NoLogo', '-ExecutionPolicy', 'Bypass'] end |
.pwsh_path(additional_paths = []) ⇒ String
Retrieves the absolute path to pwsh
350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 |
# File 'lib/pwsh.rb', line 350 def self.pwsh_path(additional_paths = []) # Environment variables on Windows are not case sensitive however ruby hash keys are. # Convert all the key names to upcase so we can be sure to find PATH etc. # Also while ruby can have difficulty changing the case of some UTF8 characters, we're # only going to use plain ASCII names so this is safe. current_path = Pwsh::Util.on_windows? ? ENV.select { |k, _| k.casecmp('PATH').zero? }.values[0] : ENV.fetch('PATH', nil) current_path = '' if current_path.nil? # Prefer any additional paths # TODO: Should we just use arrays by now instead of appending strings? search_paths = additional_paths.empty? ? current_path : additional_paths.join(File::PATH_SEPARATOR) + File::PATH_SEPARATOR + current_path # If we're on Windows, try the default installation locations as a last resort. # https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-core-on-windows?view=powershell-6#msi # https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-core-on-windows?view=powershell-7.1 if Pwsh::Util.on_windows? # TODO: What about PS 8? # TODO: Need to check on French/Turkish windows if ENV['PROGRAMFILES'] parses UTF8 names correctly # TODO: Need to ensure ENV['PROGRAMFILES'] is case insensitive, i.e. ENV['PROGRAMFiles'] should also resolve on Windows search_paths += ";#{ENV.fetch('PROGRAMFILES', nil)}\\PowerShell\\6" \ ";#{ENV.fetch('PROGRAMFILES(X86)', nil)}\\PowerShell\\6" \ ";#{ENV.fetch('PROGRAMFILES', nil)}\\PowerShell\\7" \ ";#{ENV.fetch('PROGRAMFILES(X86)', nil)}\\PowerShell\\7" end raise 'No paths discovered to search for Powershell!' if search_paths.split(File::PATH_SEPARATOR).empty? pwsh_paths = [] # TODO: THis could probably be done better, but it works! if Pwsh::Util.on_windows? search_paths.split(File::PATH_SEPARATOR).each do |path| pwsh_paths << File.join(path, 'pwsh.exe') if File.exist?(File.join(path, 'pwsh.exe')) end else search_paths.split(':').each do |path| pwsh_paths << File.join(path, 'pwsh') if File.exist?(File.join(path, 'pwsh')) end end # TODO: not sure about nil? but .empty? is MethodNotFound on nil raise 'No pwsh discovered!' if pwsh_paths.nil? || pwsh_paths.empty? pwsh_paths[0] end |
.pwsh_supported? ⇒ Bool
Determine whether or not the manager is supported on the machine for PowerShell 6+
96 97 98 |
# File 'lib/pwsh.rb', line 96 def self.pwsh_supported? !win32console_enabled? end |
.read_length_prefixed_string!(bytes) ⇒ String
The manager sends a 4-byte integer representing the number of bytes to read for the incoming string. This method reads that prefix and then reads the specified number of bytes. Mutates the given bytes, removing the length prefixed value.
445 446 447 448 449 450 451 |
# File 'lib/pwsh.rb', line 445 def self.read_length_prefixed_string!(bytes) # 32 bit integer in Little Endian format length = bytes.slice!(0, 4).unpack1('V') return nil if length.zero? bytes.slice!(0, length).force_encoding(Encoding::UTF_8) end |
.readable?(stream, timeout = 0.5) ⇒ Bool
Return whether or not a particular stream is valid and readable
414 415 416 417 418 419 |
# File 'lib/pwsh.rb', line 414 def self.readable?(stream, timeout = 0.5) raise Errno::EPIPE unless stream_valid?(stream) read_ready = IO.select([stream], [], [], timeout) read_ready && stream == read_ready[0][0] && !stream.eof? end |
.stream_valid?(stream) ⇒ Bool
When a stream has been closed by handle, but Ruby still has a file descriptor for it, it can be tricky to detemine that it’s actually dead. The .fileno will still return an int, and calling get_osfhandle against it returns what the CRT thinks is a valid Windows HANDLE value, but that may no longer exist.
428 429 430 431 432 433 434 435 436 437 |
# File 'lib/pwsh.rb', line 428 def self.stream_valid?(stream) # When a stream is closed, it's obviously invalid, but Ruby doesn't always know !stream.closed? && # So calling stat will yield and EBADF when underlying OS handle is bad # as this resolves to a HANDLE and then calls the Windows API !stream.stat.nil? # Any exceptions mean the stream is dead rescue StandardError false end |
.template_path ⇒ String
Return the path to the bootstrap template
245 246 247 248 249 250 |
# File 'lib/pwsh.rb', line 245 def self.template_path # A PowerShell -File compatible path to bootstrap the instance path = File.('templates', __dir__) path = File.join(path, 'init.ps1').tr('/', '\\') "\"#{path}\"" end |
.win32console_enabled? ⇒ Bool
Determine whether or not the Win32 Console is enabled
70 71 72 73 74 |
# File 'lib/pwsh.rb', line 70 def self.win32console_enabled? @win32console_enabled ||= defined?(Win32) && defined?(Win32::Console) && Win32::Console.instance_of?(Class) end |
.windows_powershell_supported? ⇒ Bool
Determine whether or not the manager is supported on the machine for Windows PowerShell
87 88 89 90 91 |
# File 'lib/pwsh.rb', line 87 def self.windows_powershell_supported? Pwsh::Util.on_windows? && Pwsh::WindowsPowerShell.compatible_version? && !win32console_enabled? end |
Instance Method Details
#alive? ⇒ Bool
Return whether or not the manager is running, usable, and the I/O streams remain open.
178 179 180 181 182 183 184 185 186 187 |
# File 'lib/pwsh.rb', line 178 def alive? # powershell process running @ps_process.alive? && # explicitly set during a read / write failure, like broken pipe EPIPE @usable && # an explicit failure state might not have been hit, but IO may be closed self.class.stream_valid?(@pipe) && self.class.stream_valid?(@stdout) && self.class.stream_valid?(@stderr) end |
#drain_pipe_until_signaled(pipe, signal) ⇒ Array
Read from a specified pipe for as long as the signal is locked and the pipe is readable. Then return the data as an array of UTF-8 strings.
531 532 533 534 535 536 537 538 539 540 541 542 |
# File 'lib/pwsh.rb', line 531 def drain_pipe_until_signaled(pipe, signal) output = [] read_from_pipe(pipe) { |s| output << s } while signal.locked? # There's ultimately a bit of a race here # Read one more time after signal is received read_from_pipe(pipe, 0) { |s| output << s } while self.class.readable?(pipe) # String has been binary up to this point, so force UTF-8 now output == [] ? [] : [output.join.force_encoding(Encoding::UTF_8)] end |
#exec_read_result(powershell_code) ⇒ Array
Executes PowerShell code over the PowerShell manager and returns the results.
606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 |
# File 'lib/pwsh.rb', line 606 def exec_read_result(powershell_code) write_pipe(pipe_command(:execute)) write_pipe(length_prefixed_string(powershell_code)) read_streams # If any pipes are broken, the manager is totally hosed # Bad file descriptors mean closed stream handles # EOFError is a closed pipe (could be as a result of tearing down process) # Errno::ECONNRESET is a closed unix domain socket (could be as a result of tearing down process) rescue Errno::EPIPE, Errno::EBADF, EOFError, Errno::ECONNRESET => e @usable = false [nil, nil, [e.inspect, e.backtrace].flatten] # Catch closed stream errors specifically rescue IOError => e raise unless e..start_with?('closed stream') @usable = false [nil, nil, [e.inspect, e.backtrace].flatten] end |
#execute(powershell_code, timeout_ms = nil, working_dir = nil, environment_variables = []) ⇒ Hash
Run specified powershell code via the manager
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/pwsh.rb', line 196 def execute(powershell_code, timeout_ms = nil, working_dir = nil, environment_variables = []) code = make_ps_code(powershell_code, timeout_ms, working_dir, environment_variables) # err is drained stderr pipe (not captured by redirection inside PS) # or during a failure, a Ruby callstack array out, native_stdout, err = exec_read_result(code) # an error was caught during execution that has invalidated any results return { exitcode: -1, stderr: err } if out.nil? && !@usable out[:exitcode] = out[:exitcode].to_i unless out[:exitcode].nil? # If err contains data it must be "real" stderr output # which should be appended to what PS has already captured out[:stderr] = out[:stderr].nil? ? [] : [out[:stderr]] out[:stderr] += err unless err.nil? out[:native_stdout] = native_stdout out end |
#exit ⇒ Object
Tear down the instance of the manager, shutting down the pipe and process.
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 |
# File 'lib/pwsh.rb', line 217 def exit @usable = false # TODO: Log a debug for "Pwsh exiting..." # Ask PowerShell pipe server to shutdown if its still running # rather than expecting the pipe.close to terminate it begin write_pipe(pipe_command(:exit)) unless @pipe.closed? rescue StandardError nil end # Pipe may still be open, but if stdout / stderr are deat the PS # process is in trouble and will block forever on a write to the # pipe. It's safer to close pipe on the Ruby side, which gracefully # shuts down the PS side. @pipe.close unless @pipe.closed? @stdout.close unless @stdout.closed? @stderr.close unless @stderr.closed? # Wait up to 2 seconds for the watcher thread to full exit @ps_process.join(2) end |
#length_prefixed_string(data) ⇒ Object
Take a given string and prefix it with a 4-byte length and encode for sending to the PowerShell manager. Data format is:
4 bytes - Little Endian encoded 32-bit integer length of string
Intel CPUs are little endian, hence the .NET Framework typically is
variable length - UTF8 encoded string bytes
@return A binary encoded string prefixed with a 4-byte length identifier
489 490 491 492 493 |
# File 'lib/pwsh.rb', line 489 def length_prefixed_string(data) msg = data.encode(Encoding::UTF_8) # https://ruby-doc.org/core-1.9.3/Array.html#method-i-pack [msg.bytes.length].pack('V') + msg.force_encoding(Encoding::BINARY) end |
#make_ps_code(powershell_code, timeout_ms = nil, working_dir = nil, environment_variables = []) ⇒ String
Return the block of code to be run by the manager with appropriate settings
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 |
# File 'lib/pwsh.rb', line 259 def make_ps_code(powershell_code, timeout_ms = nil, working_dir = nil, environment_variables = []) begin # Zero timeout is a special case. Other modules sometimes treat this # as an infinite timeout. We don't support infinite, so for the case # of a user specifying zero, we sub in the default value of 300s. timeout_ms = 300 * 1000 if timeout_ms.zero? timeout_ms = Integer(timeout_ms) # Lower bound protection. The polling resolution is only 50ms. timeout_ms = 50 if timeout_ms < 50 rescue StandardError timeout_ms = 300 * 1000 end # Environment array firstly needs to be parsed and converted into a hashtable. # And then the values passed in need to be converted to a PowerShell Hashtable. # # Environment parsing is based on the puppet exec equivalent code # https://github.com/puppetlabs/puppet/blob/a9f77d71e992fc2580de7705847e31264e0fbebe/lib/puppet/provider/exec.rb#L35-L49 environment = {} if (envlist = environment_variables) envlist = [envlist] unless envlist.is_a? Array envlist.each do |setting| if setting =~ /^(\w+)=((.|\n)+)$/ env_name = Regexp.last_match(1) value = Regexp.last_match(2) if environment.include?(env_name) || environment.include?(env_name.to_sym) # TODO: log a warning for "Overriding environment setting '#{env_name}' with '#{value}'" end environment[env_name] = value else # rubocop:disable Style/EmptyElse # TODO: log a warning for "Cannot understand environment setting #{setting.inspect}" end end end # Convert the Ruby Hashtable into PowerShell syntax additional_environment_variables = '@{' unless environment.empty? environment.each do |name, value| # PowerShell escapes single quotes inside a single quoted string by just adding # another single quote i.e. a value of foo'bar turns into 'foo''bar' when single quoted. ps_name = name.gsub('\'', '\'\'') ps_value = value.gsub('\'', '\'\'') additional_environment_variables += " '#{ps_name}' = '#{ps_value}';" end end additional_environment_variables += '}' # PS Side expects Invoke-PowerShellUserCode is always the return value here # TODO: Refactor to use <<~ as soon as we can :sob: <<~CODE $params = @{ Code = @' #{powershell_code} '@ TimeoutMilliseconds = #{timeout_ms} WorkingDirectory = "#{working_dir}" AdditionalEnvironmentVariables = #{additional_environment_variables} } Invoke-PowerShellUserCode @params CODE end |
#pipe_command(command) ⇒ Object
This is the command that the ruby process will send to the PowerShell process and utilizes a 1 byte command identifier
0 - Exit
1 - Execute
@return Single byte representing the specified command
472 473 474 475 476 477 478 479 |
# File 'lib/pwsh.rb', line 472 def pipe_command(command) case command when :exit "\x00" when :execute "\x01" end end |
#read_from_pipe(pipe, timeout = 0.1) {|String| ... } ⇒ Object
Read output from the PowerShell manager process via the pipe.
514 515 516 517 518 519 520 521 522 523 |
# File 'lib/pwsh.rb', line 514 def read_from_pipe(pipe, timeout = 0.1, &_block) if self.class.readable?(pipe, timeout) l = pipe.readpartial(4096) # TODO: Log a debug for "#{Time.now} PIPE> #{l}" # Since readpartial may return a nil at EOF, skip returning that value yield l unless l.nil? end nil end |
#read_streams ⇒ Array
Open threads and pipes to read stdout and stderr from the PowerShell manager, then continue to read data from the manager until either all data is returned or an error interrupts the normal flow, then return that data.
549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 |
# File 'lib/pwsh.rb', line 549 def read_streams pipe_done_reading = Mutex.new pipe_done_reading.lock # TODO: Uncomment again when implementing logging # start_time = Time.now stdout_reader = Thread.new { drain_pipe_until_signaled(@stdout, pipe_done_reading) } stderr_reader = Thread.new { drain_pipe_until_signaled(@stderr, pipe_done_reading) } pipe_reader = Thread.new(@pipe) do |pipe| # Read a Little Endian 32-bit integer for length of response expected_response_length = pipe.sysread(4).unpack1('V') next nil if expected_response_length.zero? # Reads the expected bytes as a binary string or fails buffer = '' # sysread may not return all of the requested bytes due to buffering or the # underlying IO system. Keep reading from the pipe until all the bytes are read. loop do buffer.concat(pipe.sysread(expected_response_length - buffer.length)) break if buffer.length >= expected_response_length end buffer end # TODO: Log a debug for "Waited #{Time.now - start_time} total seconds." # Block until sysread has completed or errors begin output = pipe_reader.value output = self.class.ps_output_to_hash!(output) unless output.nil? ensure # Signal stdout / stderr readers via Mutex so that # Ruby doesn't crash waiting on an invalid event. pipe_done_reading.unlock end # Given redirection on PowerShell side, this should always be empty stdout = stdout_reader.value [ output, stdout == [] ? nil : stdout.join, # native stdout stderr_reader.value # native stderr ] ensure # Failsafe if the prior unlock was never reached / Mutex wasn't unlocked pipe_done_reading.unlock if pipe_done_reading.locked? # Wait for all non-nil threads to see mutex unlocked and finish [pipe_reader, stdout_reader, stderr_reader].compact.each(&:join) end |
#write_pipe(input) ⇒ Object
Writes binary-encoded data to the PowerShell manager process via the pipe.
498 499 500 501 502 503 504 505 506 |
# File 'lib/pwsh.rb', line 498 def write_pipe(input) written = @pipe.write(input) @pipe.flush if written != input.length # rubocop:disable Style/GuardClause msg = "Only wrote #{written} out of #{input.length} expected bytes to PowerShell pipe" raise Errno::EPIPE.new, msg end end |