cuboid 0.3.6 → 0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,97 +1,31 @@
1
+ require_relative '../../server/instance_helpers'
2
+
1
3
  module Cuboid
2
4
  module Rest
3
5
  class Server
4
6
 
7
+ # Sinatra-coupled supplement to `Cuboid::Server::InstanceHelpers` —
8
+ # the methods that read `env` or call `handle_error` (a Sinatra helper
9
+ # defined on `Rest::Server`). Everything that doesn't need Sinatra
10
+ # stays on the shared module above.
5
11
  module InstanceHelpers
6
12
 
7
- @@instances = {}
8
- @@agents = {}
9
-
10
- def get_instance
11
- if agent
12
- options = {
13
- owner: self.class.to_s,
14
- helpers: {
15
- owner: {
16
- url: env['HTTP_HOST']
17
- }
18
- }
19
- }
20
-
21
- if (info = agent.spawn( options ))
22
- connect_to_instance( info['url'], info['token'] )
23
- end
24
- else
25
- Processes::Instances.spawn( application: Options.paths.application, daemonize: true )
26
- end
27
- end
28
-
29
- def agents
30
- @@agents.keys
31
- end
13
+ include ::Cuboid::Server::InstanceHelpers
32
14
 
33
- def agent
34
- return if !Options.agent.url
35
- @agent ||= connect_to_agent( Options.agent.url )
36
- end
37
-
38
- def unplug_agent( url )
39
- connect_to_agent( url ).node.unplug
40
-
41
- c = @@agents.delete( url )
42
- c.close if c
43
- end
44
-
45
- def connect_to_agent( url )
46
- @@agents[url] ||= RPC::Client::Agent.new( url )
47
- end
48
-
49
- def connect_to_instance( url, token )
50
- RPC::Client::Instance.new( url, token )
51
- end
52
-
53
- def update_from_scheduler
54
- return if !scheduler
55
-
56
- scheduler.running.each do |id, info|
57
- instances[id] ||= connect_to_instance( info['url'], info['token'] )
58
- end
59
-
60
- (scheduler.failed.keys | scheduler.completed.keys).each do |id|
61
- session.delete id
62
- client = instances.delete( id )
63
- client.close if client
64
- end
65
- end
66
-
67
- def scheduler
68
- return if !Options.scheduler.url
69
- @scheduler ||= connect_to_scheduler( Options.scheduler.url )
70
- end
71
-
72
- def connect_to_scheduler( url )
73
- RPC::Client::Scheduler.new( url )
74
- end
75
-
76
- def instances
77
- @@instances
15
+ # Forward the request host to the shared spawner so the Agent can
16
+ # log who asked for the instance.
17
+ def spawn( owner_url: env['HTTP_HOST'] )
18
+ super
78
19
  end
79
20
 
80
21
  def instance_for( id, &block )
81
- cleanup = proc do
82
- instances.delete( id ).close
83
- session.delete id
84
- end
22
+ cleanup = proc { instances.delete( id ).close }
85
23
 
86
24
  handle_error cleanup do
87
- block.call @@instances[id]
25
+ block.call instances[id]
88
26
  end
89
27
  end
90
28
 
91
- def exists?( id )
92
- instances.include? id
93
- end
94
-
95
29
  end
96
30
 
97
31
  end
@@ -20,7 +20,7 @@ module Instances
20
20
 
21
21
  options = ::JSON.load( request.body.read ) || {}
22
22
 
23
- instance = get_instance
23
+ instance = self.spawn
24
24
  max_utilization! if !instance
25
25
 
26
26
  handle_error proc { (instance.shutdown rescue nil) } do
@@ -36,20 +36,19 @@ module Instances
36
36
  app.get '/instances/:instance' do
37
37
  ensure_instance!
38
38
 
39
- session[params[:instance]] ||= {
40
- seen_errors: 0,
41
- }
39
+ # The Sinatra session is per-cookie, but its id isn't
40
+ # exposed; lazy-init a per-client UUID and pass it as
41
+ # the RPC `session:` token so the engine tracks the
42
+ # error-line offset server-side.
43
+ require 'securerandom' unless defined?( SecureRandom )
44
+ session[:rpc_session_id] ||= SecureRandom.uuid
42
45
 
43
46
  data = instance_for( params[:instance] ) do |instance|
44
47
  instance.progress(
45
- with: [
46
- errors: session[params[:instance]][:seen_errors],
47
- ]
48
+ session: "#{session[:rpc_session_id]}:#{params[:instance]}"
48
49
  )
49
50
  end
50
51
 
51
- session[params[:instance]][:seen_errors] += data[:errors].size
52
-
53
52
  json data
54
53
  end
55
54
 
@@ -110,14 +109,10 @@ module Instances
110
109
  app.delete '/instances/:instance' do
111
110
  ensure_instance!
112
111
  id = params[:instance]
113
-
114
- instance = instances[id]
115
112
  handle_error { (instance.shutdown rescue nil) }
116
113
 
117
114
  instances.delete( id ).close
118
115
 
119
- session.delete params[:instance]
120
-
121
116
  json nil
122
117
  end
123
118
 
@@ -18,7 +18,7 @@ class Server < Sinatra::Base
18
18
 
19
19
  Dir.glob( "#{File.dirname( __FILE__ )}/server/routes/*.rb" ).each { |f| require f }
20
20
 
21
- helpers InstanceHelpers
21
+ helpers ::Cuboid::Rest::Server::InstanceHelpers
22
22
 
23
23
  register Sinatra::Namespace
24
24
  Cuboid::Application.application.rest_services.each do |name, service|
@@ -320,12 +320,17 @@ class Agent
320
320
  end
321
321
 
322
322
  def spawn_instance( options = {}, &block )
323
+ # `detached: true` opts the spawned engine out of the
324
+ # base.rb parent-death watchdog: an agent restarting / dying
325
+ # must NOT take the engine with it (grid pattern — the
326
+ # instance is owned by whoever connects, not the agent).
323
327
  Processes::Instances.spawn( options.merge(
324
328
  address: @server.address,
325
329
  port_range: Options.agent.instance_port_range,
326
330
  token: Utilities.generate_token,
327
331
  application: Options.paths.application,
328
- daemonize: true
332
+ daemonize: true,
333
+ detached: true
329
334
  )) do |client|
330
335
  block.call(
331
336
  'token' => client.token,
@@ -152,51 +152,38 @@ class Instance
152
152
  # In addition, ask to **not** be served data you already have, like
153
153
  # error messages.
154
154
  #
155
- # To be kept completely up to date on the progress of a scan (i.e. receive
156
- # new issues and error messages asap) in an efficient manner, you will need
157
- # to keep track of the error messages you already have and explicitly tell
158
- # the method to not send the same data back to you on subsequent calls.
155
+ # Pass a `session:` token (any caller-chosen string) and the
156
+ # server returns only error lines past the previous offset
157
+ # under that token. Reuse the same token across polls for
158
+ # the same logical view; pick a fresh one to start fresh.
159
159
  #
160
- # ## Retrieving errors (`:errors` option) without duplicate data
161
- #
162
- # This is done by telling the method how many error messages you already
163
- # have and you will be served the errors from the error-log that are past
164
- # that line.
165
- # So, if you were to use a loop to get fresh progress data it would look
166
- # like so:
167
- #
168
- # error_cnt = 0
169
- # i = 0
160
+ # token = SecureRandom.uuid
170
161
  # while sleep 1
171
- # # Test method, triggers an error log...
172
- # instance.error_test "BOOM! #{i+=1}"
173
- #
174
- # # Only request errors we don't already have
175
- # errors = instance.progress( with: { errors: error_cnt } )[:errors]
176
- # error_cnt += errors.size
177
- #
178
- # # You will only see new errors
179
- # puts errors.join("\n")
162
+ # errors = instance.progress( session: token )[:errors]
163
+ # puts errors.join( "\n" )
180
164
  # end
181
165
  #
182
- # @param [Hash] options
183
- # Options about what progress data to retrieve and return.
184
- # @option options [Array<Symbol, Hash>] :with
185
- # Specify data to include:
186
- #
187
- # * :errors -- Errors and the line offset to use for {#errors}.
188
- # Pass as a hash, like: `{ errors: 10 }`
189
- # @option options [Array<Symbol, Hash>] :without
190
- # Specify data to exclude:
166
+ # Without `session`, callers must opt into errors via
167
+ # `with: [:errors]` and will receive the full set every poll.
191
168
  #
192
- # * :statistics -- Don't include runtime statistics.
169
+ # @param [Hash] options
170
+ # @option options [String, Symbol] :session
171
+ # Caller-chosen session token. When provided, the response
172
+ # carries only errors past the previously emitted offset.
173
+ # @option options [Array<Symbol>] :with
174
+ # Block names to include when no session is in use. Currently
175
+ # only `:errors` is delta-able.
176
+ # @option options [Array<Symbol>] :without
177
+ # Block names to exclude. One or more of `:statistics`,
178
+ # `:errors`. Takes precedence over `with:` and over the
179
+ # session-on-by-default blocks.
193
180
  #
194
181
  # @return [Hash]
195
182
  # * `statistics` -- General runtime statistics (merged when part of Grid)
196
183
  # (enabled by default)
197
184
  # * `status` -- {#status}
198
185
  # * `busy` -- {#busy?}
199
- # * `errors` -- {#errors} (disabled by default)
186
+ # * `errors` -- {#errors}
200
187
  def progress( options = {} )
201
188
  progress_handler( options.merge( as_hash: true ) )
202
189
  end
@@ -226,6 +213,27 @@ class Instance
226
213
  end
227
214
 
228
215
  # Makes the server go bye-bye...Lights out!
216
+ #
217
+ # `shutdown` must reliably take the Ruby process with it. Stopping
218
+ # the reactor + RPC server alone leaves the Application's non-daemon
219
+ # threads (audit workers, browser cluster manager, etc.) blocking
220
+ # the runtime — historically this leaked engine subprocesses every
221
+ # time `kill_instance` was called over MCP, and showed up in the
222
+ # cuboid spec suite as leftover ruby processes after the run.
223
+ # The `instance.shutdown` RPC returned success but the daemonised
224
+ # process never actually exited.
225
+ #
226
+ # Two-stage exit:
227
+ # 1. Raise SystemExit on the **main thread** so the at_exit
228
+ # chain runs (Cuboid_<pid> tmpdir cleanup, live-plugin's
229
+ # `exited` push). SystemExit raised on a non-main thread
230
+ # only kills that thread — must hit the main one.
231
+ # 2. Watchdog SIGKILL after a grace window in case a
232
+ # non-daemon Application thread refuses to release. The
233
+ # Paths boot-sweep reaps the orphaned tmpdir on the next
234
+ # cuboid process launch even when at_exit didn't run.
235
+ SHUTDOWN_GRACE_SECONDS = 5.0
236
+
229
237
  def shutdown( &block )
230
238
  if @shutdown
231
239
  block.call if block_given?
@@ -243,6 +251,17 @@ class Instance
243
251
  @server.shutdown
244
252
  @raktr.stop
245
253
  block.call true if block_given?
254
+
255
+ # Stage 1 — graceful: SystemExit on the main thread so
256
+ # at_exit handlers run.
257
+ main = Thread.main
258
+ if main && main.alive? && main != Thread.current
259
+ main.raise( SystemExit.new( 0 ) ) rescue nil
260
+ end
261
+
262
+ # Stage 2 — watchdog: hammer if main can't unwind.
263
+ sleep SHUTDOWN_GRACE_SECONDS
264
+ Process.kill( 'KILL', Process.pid ) rescue nil
246
265
  end
247
266
 
248
267
  true
@@ -262,49 +281,50 @@ class Instance
262
281
  [Process.pid]
263
282
  end
264
283
 
265
- def self.parse_progress_opts( options, key )
266
- parsed = {}
267
- [options.delete( key ) || options.delete( key.to_s )].compact.each do |w|
268
- case w
269
- when Array
270
- w.compact.flatten.each do |q|
271
- case q
272
- when String, Symbol
273
- parsed[q.to_sym] = nil
274
-
275
- when Hash
276
- parsed.merge!( q.my_symbolize_keys )
277
- end
278
- end
279
-
280
- when String, Symbol
281
- parsed[w.to_sym] = nil
282
-
283
- when Hash
284
- parsed.merge!( w.my_symbolize_keys )
285
- end
286
- end
287
-
288
- parsed
284
+ def self.parse_block_names( raw )
285
+ return [] if raw.nil?
286
+ Array( raw ).flatten.compact.map(&:to_sym)
289
287
  end
290
288
 
291
289
  private
292
290
 
291
+ # Server-side state for `session:`-tracked progress polls.
292
+ # Keyed off a caller-supplied token so RPC clients don't have
293
+ # to re-transmit the error line offset on every poll.
294
+ def progress_sessions
295
+ @progress_sessions ||= {}
296
+ end
297
+
298
+ def progress_session_for( id )
299
+ progress_sessions[id] ||= { seen_errors: 0 }
300
+ end
301
+
293
302
  def progress_handler( options = {}, &block )
294
- with = self.class.parse_progress_opts( options, :with )
295
- without = self.class.parse_progress_opts( options, :without )
303
+ options = options.my_symbolize_keys
304
+
305
+ session_id = options.delete( :session )
306
+ session = progress_session_for( session_id ) if session_id
296
307
 
297
- options = {
308
+ with = self.class.parse_block_names( options[:with] )
309
+ without = self.class.parse_block_names( options[:without] )
310
+
311
+ # Under a session, errors are on by default; without a
312
+ # session, callers opt in via `with: [:errors]`.
313
+ include_errors = !without.include?( :errors ) && (session || with.include?( :errors ))
314
+
315
+ wrapper_options = {
298
316
  as_hash: options[:as_hash],
299
317
  statistics: !without.include?( :statistics )
300
318
  }
319
+ wrapper_options[:errors] = session ? session[:seen_errors] : 0 if include_errors
301
320
 
302
- if with[:errors]
303
- options[:errors] = with[:errors]
304
- end
305
-
306
- @application.progress( options ) do |data|
321
+ @application.progress( wrapper_options ) do |data|
307
322
  data[:busy] = busy?
323
+
324
+ if session && data[:errors]
325
+ session[:seen_errors] += data[:errors].size
326
+ end
327
+
308
328
  block.call( data )
309
329
  end
310
330
  end
@@ -0,0 +1,131 @@
1
+ module Cuboid
2
+ module Server
3
+
4
+ # Shared registry + lookup helpers for the running engine instances
5
+ # any front-end (REST, MCP, scheduler-sync) drives. The two
6
+ # class-variables (`@@instances`, `@@agents`) are intentionally
7
+ # module-level so every includer sees the same map without explicit
8
+ # cross-process plumbing.
9
+ #
10
+ # `spawn` here picks an Agent if one is configured (so grid mode keeps
11
+ # working) or falls back to local `Processes::Instances.spawn`.
12
+ # Sinatra-only surface — `instance_for`, REST-side scheduler-session
13
+ # cleanup, and the env-derived owner URL on `spawn` — lives on
14
+ # `Cuboid::Rest::Server::InstanceHelpers`, which mixes this in.
15
+ module InstanceHelpers
16
+
17
+ @@instances = {}
18
+ @@agents = {}
19
+
20
+ def self.instances
21
+ @@instances
22
+ end
23
+
24
+ # Spawn a new engine instance. If an Agent URL is configured the
25
+ # instance is provisioned via the Agent (grid path); otherwise we
26
+ # fork a local one via `Processes::Instances.spawn`.
27
+ #
28
+ # `owner_url` is forwarded to the Agent as `helpers.owner.url` —
29
+ # purely metadata identifying who asked. Sinatra/REST callers pass
30
+ # `env['HTTP_HOST']`; MCP and other non-Rack callers can leave it
31
+ # nil or pass whatever they have. Module-level so callers without
32
+ # an includer context (e.g. `MCP::CoreTools::SpawnInstance`) can
33
+ # use it as `Cuboid::Server::InstanceHelpers.spawn`.
34
+ def self.spawn( owner_url: nil )
35
+ if (a = agent)
36
+ options = {
37
+ owner: name,
38
+ helpers: { owner: { url: owner_url } }
39
+ }
40
+
41
+ if (info = a.spawn( options ))
42
+ connect_to_instance( info['url'], info['token'] )
43
+ end
44
+ else
45
+ ::Cuboid::Processes::Instances.spawn(
46
+ application: ::Cuboid::Options.paths.application,
47
+ daemonize: true
48
+ )
49
+ end
50
+ end
51
+
52
+ def self.agent
53
+ return if !::Cuboid::Options.agent.url
54
+ @@agents[::Cuboid::Options.agent.url] ||=
55
+ ::Cuboid::RPC::Client::Agent.new( ::Cuboid::Options.agent.url )
56
+ end
57
+
58
+ def self.connect_to_agent( url )
59
+ @@agents[url] ||= ::Cuboid::RPC::Client::Agent.new( url )
60
+ end
61
+
62
+ def self.connect_to_instance( url, token )
63
+ ::Cuboid::RPC::Client::Instance.new( url, token )
64
+ end
65
+
66
+ def agents
67
+ @@agents.keys
68
+ end
69
+
70
+ def agent
71
+ InstanceHelpers.agent
72
+ end
73
+
74
+ def spawn( owner_url: nil )
75
+ InstanceHelpers.spawn( owner_url: owner_url )
76
+ end
77
+
78
+ def unplug_agent( url )
79
+ InstanceHelpers.connect_to_agent( url ).node.unplug
80
+
81
+ c = @@agents.delete( url )
82
+ c.close if c
83
+ end
84
+
85
+ def connect_to_agent( url )
86
+ InstanceHelpers.connect_to_agent( url )
87
+ end
88
+
89
+ def connect_to_instance( url, token )
90
+ InstanceHelpers.connect_to_instance( url, token )
91
+ end
92
+
93
+ # Pulls scheduler-tracked running instances into the local map and
94
+ # closes/removes any that the scheduler reports failed or completed.
95
+ # Sinatra-side session cleanup for the same IDs is the responsibility
96
+ # of `Cuboid::Rest::Server::InstanceHelpers#update_from_scheduler`,
97
+ # which calls super then prunes its session.
98
+ def update_from_scheduler
99
+ return if !scheduler
100
+
101
+ scheduler.running.each do |id, info|
102
+ instances[id] ||= connect_to_instance( info['url'], info['token'] )
103
+ end
104
+
105
+ (scheduler.failed.keys | scheduler.completed.keys).each do |id|
106
+ client = instances.delete( id )
107
+ client.close if client
108
+ end
109
+ end
110
+
111
+ def scheduler
112
+ return if !Options.scheduler.url
113
+ @scheduler ||= connect_to_scheduler( Options.scheduler.url )
114
+ end
115
+
116
+ def connect_to_scheduler( url )
117
+ RPC::Client::Scheduler.new( url )
118
+ end
119
+
120
+ def instances
121
+ InstanceHelpers.instances
122
+ end
123
+
124
+ def exists?( id )
125
+ instances.include? id
126
+ end
127
+
128
+ end
129
+
130
+ end
131
+ end
data/lib/version CHANGED
@@ -1 +1 @@
1
- 0.3.6
1
+ 0.5