Files
vamp/src/ruby_host.rb
T

134 lines
4.2 KiB
Ruby

# frozen_string_literal: true
require "json"
module Vamp
# Owns the subprocess wire protocol, isolated from user stdout and callbacks.
class Protocol
# @return [void]
def initialize
# Capture physical streams; globals may be replaced by user code.
@output = STDOUT.dup # rubocop:disable Style/GlobalStdStream
@output.binmode
@output.sync = true
@input = STDIN.dup # rubocop:disable Style/GlobalStdStream
@input.binmode
@mutex = Mutex.new
# Explicit STDOUT.write must also stay off the duplicated protocol pipe.
STDOUT.reopen(STDERR) # rubocop:disable Style/GlobalStdStream
$stdout = STDERR # rubocop:disable Style/GlobalStdStream
Thread.abort_on_exception = true
end
# @param arguments [Array<String>] mode, source path, and startup JSON
# @return [Object] the shutdown callback's result
def run(arguments)
start(arguments)
send_frame(Vamp.startup)
process_requests if @mode == "plugin"
Vamp.shutdown
ensure
# Includes SystemExit/Interrupt, without rescuing or hiding callback errors.
error = $! # rubocop:disable Style/SpecialGlobalVars -- Native exception state at the process boundary.
report_error(error) if error
end
private
# @param arguments [Array<String>]
# @return [Boolean] the source load result
def start(arguments)
@mode, path, encoded_startup = arguments
valid = %w[config plugin].include?(@mode) && path && encoded_startup
raise ArgumentError, "expected config or plugin mode, path, and startup data" unless valid
raise ArgumentError, "startup frame too large" if encoded_startup.bytesize > MAX_FRAME
Vamp.setup(@mode, JSON.parse(encoded_startup), method(:send_frame))
load path
end
# @param frame [Hash{String => Object}]
# @return [Integer] bytes written by the terminating newline
def send_frame(frame)
@mutex.synchronize do
payload = JSON.generate(frame)
raise ArgumentError, "outgoing frame too large" if payload.bytesize > MAX_FRAME
@output.write(payload)
@output.write("\n")
end
end
# @return [void]
def process_requests
loop do
@request_id = nil
line = @input.gets(MAX_FRAME + 2)
break if line.nil?
request = parse_request(line)
remember_request(request)
dispatch(request)
break if request["type"] == "shutdown"
end
end
# @param line [String]
# @return [Object] parsed JSON, validated by the selected request handler
def parse_request(line)
unless line.end_with?("\n") && line.bytesize - 1 <= MAX_FRAME
raise ArgumentError, "invalid or oversized protocol frame"
end
JSON.parse(line)
end
# @param request [Object]
# @return [Integer, nil] ID to attach to a failed invocation
def remember_request(request)
return unless request.is_a?(Hash)
id = request["id"]
@request_id = id if id.is_a?(Integer) && id.positive?
end
# @param request [Object]
# @return [Object]
def dispatch(request)
case request.is_a?(Hash) && request["type"]
when "invoke"
send_frame(Vamp.invoke(request))
when "document"
Vamp.document(request)
when "shutdown"
validate_shutdown(request)
else
raise ArgumentError, "unknown protocol frame"
end
end
# @param request [Hash{String => Object}]
# @return [nil]
def validate_shutdown(request)
return if request.keys.sort == %w[type version] && request["version"].is_a?(Integer) && request["version"] == 1
raise ArgumentError, "invalid shutdown frame"
end
# @param error [Exception]
# @return [void]
def report_error(error)
# No callback effects survive an exception. Bound and sanitize the message.
message = "#{error.class}: #{error.message}".encode("UTF-8", invalid: :replace, undef: :replace)
frame = { "version" => 1, "type" => "error", "message" => message.each_char.take(2048).join }
frame["id"] = @request_id unless @request_id.nil?
send_frame(frame)
ensure
# If even reporting fails, Rust observes EOF and invalidates the host.
exit 1
end
end
end
Vamp::Protocol.new.run(ARGV)