Class: Canopus::Plugins::IsolatedRuntime

Inherits:
Object
  • Object
show all
Defined in:
lib/canopus/plugins/isolated_runtime.rb

Constant Summary collapse

WORKER =
<<~'RUBY'
  require "json"
  require "stringio"
  require "open3"
  protocol = STDOUT
  $stdout = StringIO.new
  class PluginAPI
    attr_reader :edits, :messages
    def initialize(context, permissions)
      @context, @permissions, @edits, @messages = context, permissions, [], []
    end
    def permit!(name)
      normalized = name.to_s == "process" ? "exec" : name.to_s
      raise "plugin requires #{name}" unless @permissions.include?(normalized)
    end
    def text
      permit!("read_buffer")
      @context.fetch("text") { raise @context.fetch("text_error", "buffer text is not available") }
    end
    def files
      permit!("read_project")
      @context.fetch("files")
    end
    def replace(range, text)
      permit!("edit_buffer")
      @edits << [range.begin, range.end + (range.exclude_end? ? 0 : 1), text]
    end
    def notify(message) = @messages << message.to_s
    def run(command)
      permit!("exec")
      raise "command must be an argument array" unless command.is_a?(Array) && !command.empty?
      Open3.capture3(*command, chdir: @context.fetch("root"))
    end
    def http_get(url)
      permit!("network")
      require "net/http"
      uri = URI(url)
      raise "HTTP or HTTPS URL required" unless %w[http https].include?(uri.scheme)
      body = +""
      Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
        http.request_get(uri.request_uri) do |response|
          response.read_body do |chunk|
            raise "plugin HTTP response exceeds 1 MiB" if body.bytesize + chunk.bytesize > 1 << 20
            body << chunk
          end
        end
      end
      body
    end
  end
  class PluginDefinition
    attr_reader :actions, :panels, :languages, :servers
    def initialize
      @actions, @panels, @languages, @servers = {}, {}, {}, {}
    end
    def register_action(name, description: name, &block) = @actions[name] = [description, block]
    def register_panel(name, side: :right, &block) = @panels[name] = [side, block]
    def register_language(name, **options) = @languages[name] = options
    def configure_lsp(language, command) = @servers[language] = command
  end
  begin
    bootstrap = JSON.parse(STDIN.gets || raise("missing plugin source"))
    definition = PluginDefinition.new
    definition.instance_eval(bootstrap.fetch("source"), bootstrap.fetch("path"), 1)
    protocol.puts(JSON.generate(actions: definition.actions.transform_values(&:first), panels: definition.panels.transform_values(&:first), languages: definition.languages, servers: definition.servers))
    protocol.flush
    STDIN.each_line do |line|
      begin
        request = JSON.parse(line)
        api = PluginAPI.new(request.fetch("context"), bootstrap.fetch("permissions"))
        table = request["kind"] == "panel" ? definition.panels : definition.actions
        result = table.fetch(request.fetch("name"))[1].call(api)
        protocol.puts(JSON.generate(result: result.is_a?(String) ? result : nil, edits: api.edits, messages: api.messages))
      rescue StandardError, ScriptError => error
        protocol.puts(JSON.generate(error: "#{error.class}: #{error.message}"))
      end
      protocol.flush
      $stdout.truncate(0)
      $stdout.rewind
    end
  rescue StandardError, ScriptError => error
    protocol.puts(JSON.generate(error: "#{error.class}: #{error.message}"))
    protocol.flush
  end
RUBY

Instance Method Summary collapse

Constructor Details

#initialize(workspace, source, path, permissions, timeout: 2) ⇒ IsolatedRuntime

Returns a new instance of IsolatedRuntime.



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/canopus/plugins/isolated_runtime.rb', line 98

def initialize(workspace, source, path, permissions, timeout: 2)
  @workspace, @plugin_id = workspace, path
  @permissions, @timeout = permissions.map { |permission| permission.to_s == "process" ? "exec" : permission.to_s }.uniq, timeout
  @panel_lock = Mutex.new
  @panel_cache, @panel_refreshed, @panel_refreshing, @panel_threads = {}, {}, {}, {}
  @closed = false
  start_process
  @input.sync = true
  @output.binmode
  @pending, @lock = +"".b, Mutex.new
  @input.puts(JSON.generate(source: source, path: path, permissions: @permissions))
  manifest = response
  servers = manifest.fetch("servers")
  if !servers.empty? && !@permissions.include?("exec")
    raise Canopus::Plugins::PermissionDenied, "plugin requires process to configure language servers"
  end
  manifest.fetch("actions").each do |name, description|
    workspace.register_action(name, description: description) { invoke(:action, name) }
  end
  manifest.fetch("panels").each do |name, side|
    workspace.register_panel(name, side: side.to_sym, cache: false) { panel_element(name) }
  end
  @panel_names = manifest.fetch("panels").keys
  manifest.fetch("languages").each { |name, options| workspace.register_language(name, **options.transform_keys(&:to_sym)) }
  servers.each { |language, command| workspace.configure_plugin_language_server(@plugin_id, language, command) }
rescue StandardError
  close
  raise
end

Instance Method Details

#close ⇒ Object



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/canopus/plugins/isolated_runtime.rb', line 156

def close
  threads = @panel_lock.synchronize do
    @closed = true
    @panel_threads.keys
  end
  threads.each do |thread|
    next if thread.equal?(Thread.current)
    thread.kill
    thread.join(0.2)
  end
  @input&.close unless @input&.closed?
  if @process && !@process.join(0.2)
    Process.kill("KILL", @pid) rescue Errno::ESRCH
    @process.join
  end
  @output&.close unless @output&.closed?
end

#invoke(kind, name) ⇒ Object



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
# File 'lib/canopus/plugins/isolated_runtime.rb', line 127

def invoke(kind, name)
  @lock.synchronize do
    raise Canopus::Error, "plugin runtime is closed" if @closed
    buffer = @workspace.editor&.buffer
    version = buffer&.version
    context = {root: @workspace.root}
    if @permissions.include?("read_buffer")
      if buffer && buffer.rope.bytesize <= Plugins::BUFFER_CONTEXT_LIMIT
        context[:text] = buffer.text
      else
        context[:text_error] = "plugin buffer text exceeds 1 MiB"
      end
    end
    context[:files] = @workspace.files if @permissions.include?("read_project")
    @input.puts(JSON.generate(kind: kind, name: name, context: context))
    result = response
    edits = result.fetch("edits")
    unless edits.empty?
      raise Canopus::Plugins::PermissionDenied, "plugin requires edit_buffer" unless @permissions.include?("edit_buffer")
      raise Canopus::Error, "buffer changed while plugin was running" unless buffer && buffer.version == version
      buffer.edit(edits.map { |first, last, text| [first...last, text] }, kind: :plugin)
    end
    @workspace.message = result.fetch("messages").last.to_s unless result.fetch("messages").empty?
    refresh_panels if kind == :action
    result["result"]
  end
rescue IOError, Errno::EPIPE, EOFError => error
  raise Canopus::Error, "plugin process ended: #{error.message}"
end