Class: Gienah::Host

Inherits:
Object
  • Object
show all
Defined in:
lib/gienah/host.rb,
sig/gienah.rbs

Constant Summary collapse

Definition =
Value.define(:method, :capability, :handler)
DEFAULT_RESTART =
{attempts: 3, window: 60, backoff: [1, 5, 30]}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(api_version:, dispatch: nil, sandbox: true, restart: {}, limits: {}, transport_factory: nil) ⇒ Host

Returns a new instance of Host.

Parameters:

  • api_version: (Integer)
  • dispatch: (^(Instance, String, json) -> untyped) (defaults to: nil)
  • sandbox: (Boolean) (defaults to: true)
  • restart: (Hash[Symbol, untyped]) (defaults to: {})
  • limits: (Hash[Symbol, Numeric]) (defaults to: {})
  • transport_factory: (^(Manifest) { (Manifest) -> Transport }) (defaults to: nil)

Raises:

  • (ArgumentError)


10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# File 'lib/gienah/host.rb', line 10

def initialize(api_version:, dispatch: nil, sandbox: true, restart: {}, limits: {}, transport_factory: nil)
  raise ArgumentError, "api_version must be positive" unless api_version.is_a?(Integer) && api_version.positive?

  @api_version = api_version
  @dispatch = dispatch
  @sandbox = sandbox
  @restart = restart || {}
  @limits = limits || {}
  @transport_factory = transport_factory
  @manifests = {}
  @instances = {}
  @definitions = {}
  @contribution_handlers = []
  @error_handlers = []
  @lock = Mutex.new
  @restart_history = Hash.new { |hash, key| hash[key] = [] }
  @disabled = {}
  @shutting_down = false
end

Instance Attribute Details

#api_version ⇒ Integer (readonly)

Returns the value of attribute api_version.

Returns:

  • (Integer)


8
9
10
# File 'lib/gienah/host.rb', line 8

def api_version
  @api_version
end

Instance Method Details

#activate(id, reason:) ⇒ Instance?

Parameters:

  • id (String)
  • reason: (String)

Returns:

Raises:



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/gienah/host.rb', line 65

def activate(id, reason:)
  manifest = @lock.synchronize { @manifests.fetch(id.to_s) { raise ArgumentError, "unknown plugin: #{id}" } }
  raise LifecycleError, "plugin #{id} is disabled" if @lock.synchronize { @disabled.key?(id.to_s) }
  return @instances[id.to_s] if @instances.key?(id.to_s)
  return nil unless activation_matches?(manifest.activation, reason.to_s)

  instance = Instance.new(self, manifest)
  @lock.synchronize { @instances[manifest.id] = instance }
  begin
    instance.start
  rescue StandardError
    @lock.synchronize { @instances.delete(manifest.id) }
    raise
  end
  instance
end

#add(manifest) ⇒ Manifest

Parameters:

Returns:

Raises:

  • (ArgumentError)


53
54
55
56
57
58
59
60
61
62
63
# File 'lib/gienah/host.rb', line 53

def add(manifest)
  raise ArgumentError, "expected Gienah::Manifest" unless manifest.is_a?(Manifest)
  raise LifecycleError, "unsupported plugin api version #{manifest.api_version}" unless manifest.api_version == @api_version

  @lock.synchronize do
    @manifests[manifest.id] = manifest
    @disabled.delete(manifest.id)
  end
  @contribution_handlers.each { |handler| handler.call(manifest.id, manifest.contributes) }
  manifest
end

#deactivate(id) ⇒ Object

Parameters:

  • id (String)

Returns:

  • (Object)


82
83
84
85
# File 'lib/gienah/host.rb', line 82

def deactivate(id)
  instance = @lock.synchronize { @instances.delete(id.to_s) }
  instance&.shutdown
end

#disabled?(id) ⇒ Boolean

Parameters:

  • id (String)

Returns:

  • (Boolean)


91
92
93
# File 'lib/gienah/host.rb', line 91

def disabled?(id)
  @lock.synchronize { @disabled.key?(id.to_s) }
end

#discover(directories) ⇒ Array[Manifest]

Parameters:

  • directories (Array[String])

Returns:



39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/gienah/host.rb', line 39

def discover(directories)
  manifests = Array(directories).flat_map do |directory|
    path = File.expand_path(directory.to_s)
    candidates = if File.file?(path)
      [path]
    else
      Dir[File.join(path, "**", "plugin.{json,jsonc}")]
    end
    candidates.sort.map { |manifest| Manifest.load(manifest) }
  end
  manifests.each { |manifest| add(manifest) }
  manifests
end

#dispatch_notification(instance, message) ⇒ Object



140
141
142
143
144
# File 'lib/gienah/host.rb', line 140

def dispatch_notification(instance, message)
  @dispatch&.call(instance, message["method"], message.fetch("params", {}))
rescue StandardError => error
  report_error(error, instance)
end

#dispatch_request(instance, message) ⇒ Object



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/gienah/host.rb', line 116

def dispatch_request(instance, message)
  method = message["method"]
  definition = @definitions[method]
  unless definition
    return Protocol.response(message["id"], error: {"code" => -32601, "message" => "method not found: #{method}"})
  end
  if definition.capability && !instance.granted?(definition.capability)
    return Protocol.response(message["id"], error: {"code" => -32001, "message" => "capability denied: #{definition.capability}"})
  end

  params = message.fetch("params", {})
  result = if definition.handler.arity == 1
    definition.handler.call(params)
  else
    definition.handler.call(instance, params)
  end
  Protocol.response(message["id"], result: result)
rescue CapabilityDenied => error
  Protocol.response(message["id"], error: {"code" => -32001, "message" => error.message})
rescue StandardError => error
  report_error(error, instance)
  Protocol.response(message["id"], error: {"code" => -32000, "message" => "#{error.class}: #{error.message}"})
end

#expose(method, capability: nil) {|arg0, arg1| ... } ⇒ Host

Parameters:

  • method (String)
  • capability: (String, nil) (defaults to: nil)

Yields:

Yield Parameters:

Yield Returns:

  • (Object)

Returns:

Raises:

  • (ArgumentError)


30
31
32
33
34
35
36
37
# File 'lib/gienah/host.rb', line 30

def expose(method, capability: nil, &handler)
  raise ArgumentError, "method must be a nonempty String" unless method.is_a?(String) && !method.empty?
  raise ArgumentError, "handler required" unless handler
  raise ArgumentError, "capability must be a String" unless capability.nil? || capability.is_a?(String)

  @definitions[method] = Definition.new(method, capability, handler)
  self
end

#instance_failed(instance, error) ⇒ Object



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/gienah/host.rb', line 152

def instance_failed(instance, error)
  report_error(error, instance)
  config = DEFAULT_RESTART.merge(@restart.transform_keys(&:to_sym))
  attempts = config.fetch(:attempts).to_i
  return if attempts <= 0 || @shutting_down
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  history = @lock.synchronize do
    next unless @instances[instance.id].equal?(instance)

    @restart_history[instance.id].reject! { |started| now - started >= config.fetch(:window).to_f }
    if @restart_history[instance.id].length < attempts
      @restart_history[instance.id] << now
    else
      nil
    end
  end
  unless history
    @lock.synchronize { @disabled[instance.id] = true }
    report_error(LifecycleError.new("plugin #{instance.id} disabled after repeated failures"), instance)
    return
  end

  index = history.length - 1
  backoff = Array(config.fetch(:backoff))
  delay = backoff[index] || backoff.last || 0
  Thread.new do
    sleep(delay.to_f) if delay.to_f.positive?
    next unless @lock.synchronize { !@shutting_down && @instances[instance.id].equal?(instance) && instance.state == :failed }

    instance.restart
  rescue StandardError => restart_error
    report_error(restart_error, instance)
  end
end

#instances ⇒ Array[Instance]

Returns:



87
88
89
# File 'lib/gienah/host.rb', line 87

def instances
  @lock.synchronize { @instances.values.dup }
end

#on_contribution {|arg0, arg1| ... } ⇒ Host

Yields:

Yield Parameters:

  • arg0 (String)
  • arg1 (Hash[String, json])

Yield Returns:

  • (Object)

Returns:

Raises:

  • (ArgumentError)


95
96
97
98
99
100
# File 'lib/gienah/host.rb', line 95

def on_contribution(&block)
  raise ArgumentError, "callback required" unless block

  @contribution_handlers << block
  self
end

#on_error {|arg0, arg1| ... } ⇒ Host

Yields:

Yield Parameters:

Yield Returns:

  • (Object)

Returns:

Raises:

  • (ArgumentError)


102
103
104
105
106
107
# File 'lib/gienah/host.rb', line 102

def on_error(&block)
  raise ArgumentError, "callback required" unless block

  @error_handlers << block
  self
end

#report_error(error, instance = nil) ⇒ Object



146
147
148
149
150
# File 'lib/gienah/host.rb', line 146

def report_error(error, instance = nil)
  @error_handlers.each { |handler| handler.call(error, instance) }
rescue StandardError
  nil
end

#request_timeout(manifest) ⇒ Object



200
201
202
203
# File 'lib/gienah/host.rb', line 200

def request_timeout(manifest)
  value = manifest.limits.fetch("request_timeout_ms", @limits.fetch(:request_timeout_ms, 2_000))
  value.to_f / 1_000
end

#shutdown ⇒ Object

Returns:

  • (Object)


109
110
111
112
113
114
# File 'lib/gienah/host.rb', line 109

def shutdown
  @shutting_down = true
  instances.each(&:shutdown)
  @lock.synchronize { @instances.clear }
  nil
end

#transport_for(instance, &receive) ⇒ Object



187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/gienah/host.rb', line 187

def transport_for(instance, &receive)
  if @transport_factory
    @transport_factory.call(instance.manifest, &receive)
  else
    Sandbox.ensure_safe!(instance.manifest.capabilities) if @sandbox
    entry = File.expand_path(instance.manifest.entry, instance.manifest.root)
    raise Error, "plugin entry does not exist: #{entry}" unless File.file?(entry)
    command = [RbConfig.ruby, "-I", File.expand_path("..", __dir__), entry]
    policy = @sandbox && Sandbox.available? ? Sandbox.policy_for(instance.manifest.capabilities, root: instance.manifest.root) : nil
    Transport.open(command, cwd: instance.manifest.root, env: {}, policy: policy, &receive)
  end
end