asterism 0.6.0 → 0.7.1

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.
@@ -27,7 +27,7 @@ module Asterism
27
27
  # Opens a Zenoh session and makes a Connection for nodes on it. domain:
28
28
  # the ROS domain of the nodes made by ros.node. type_path: directories
29
29
  # with generated types to add to Asterism::ROS::TYPE_PATH. The other
30
- # options go to Asterism::Zenoh::Session.open. With a block: yields the
30
+ # options (credentials: among them) go to Asterism::Zenoh.open. With a block: yields the
31
31
  # Connection, closes it when the block ends and returns its value.
32
32
  def self.connect(locator = nil, domain: 0, type_path: nil, interval: Runner::DEFAULT_INTERVAL, **opts)
33
33
  Array(type_path).each { |d| TYPE_PATH << File.expand_path(d) unless TYPE_PATH.include?(File.expand_path(d)) }
@@ -69,8 +69,13 @@ module Asterism
69
69
 
70
70
  # Opens a session (the arguments of Session.open) and wraps it in a
71
71
  # Connection. With a block: yields it, closes it when the block ends
72
- # (also on an exception) and returns the block's value.
73
- def self.open(locator = nil, interval: Runner::DEFAULT_INTERVAL, **opts)
72
+ # (also on an exception) and returns the block's value. credentials: a
73
+ # .tar from an Asterism console (Asterism::Credentials): mutual TLS with
74
+ # its certificates, to its router unless a locator is given.
75
+ def self.open(locator = nil, interval: Runner::DEFAULT_INTERVAL, credentials: nil, **opts)
76
+ if credentials
77
+ locator, opts[:config], = ::Asterism::Credentials.merge(credentials, locator, opts[:config])
78
+ end
74
79
  conn = Connection.new(Session.open(locator, **opts), interval: interval)
75
80
  return conn unless block_given?
76
81
  begin
@@ -358,6 +363,12 @@ module Asterism
358
363
  @session.closed?
359
364
  end
360
365
 
366
+ # True while the router is away and zenoh-c opens the connection
367
+ # again (a client session; what was declared comes back with it).
368
+ def reconnecting?
369
+ @session.reconnecting?
370
+ end
371
+
361
372
  def zid
362
373
  @session.zid
363
374
  end
@@ -9,12 +9,53 @@ module Asterism
9
9
  ROS::TYPE_PATH.replace([MSGS_DIR])
10
10
 
11
11
  module Codec
12
- # CRuby's msgpack packs binary (ASCII-8BIT) Strings, such as payloads
13
- # that came from Zenoh, as the bin type. The mruby MessagePack of the
14
- # boards reads only the str type, so every String goes as str
15
- # (compatibility mode), which is what the boards send too.
12
+ INT_MIN = -(2**63)
13
+ INT_MAX = 2**63 - 1
14
+
15
+ # msgpack-ruby packs a binary (ASCII-8BIT) String, such as a payload
16
+ # that came from Zenoh or File.binread, as a bin and any other String
17
+ # as a str (docs/protocol.md 3.1): no compatibility mode.
16
18
  def self.pack(v)
17
- ::MessagePack.pack(check(v), compatibility_mode: true)
19
+ ::MessagePack.pack(check(v))
20
+ end
21
+
22
+ class << self
23
+ alias_method :check_portable, :check
24
+
25
+ # An Integer outside the signed 64-bit range cannot be sent (the
26
+ # other implementations cannot hold it): EncodeError, before sending.
27
+ def check(v, depth = 0)
28
+ if v.is_a?(Integer) && (v < INT_MIN || v > INT_MAX)
29
+ raise ::Asterism::EncodeError, "Integer #{v} is outside the signed 64-bit range"
30
+ end
31
+ return v if v.is_a?(Integer)
32
+ check_portable(v, depth)
33
+ end
34
+
35
+ alias_method :unpack_portable, :unpack
36
+
37
+ # Also nil for an integer above 2^63-1 (msgpack-ruby reads a uint64;
38
+ # the protocol carries signed 64-bit integers only).
39
+ def unpack(bytes)
40
+ v = unpack_portable(bytes)
41
+ return v unless bytes&.b&.include?("\xcf".b)
42
+ int64_only?(v) ? v : nil
43
+ end
44
+
45
+ def int64_only?(v)
46
+ case v
47
+ when Integer then v <= INT_MAX
48
+ when Array then v.all? { int64_only?(_1) }
49
+ when Hash then v.all? { |k, x| int64_only?(k) && int64_only?(x) }
50
+ else true
51
+ end
52
+ end
53
+
54
+ # A text field of the protocol is a str, whatever the String's
55
+ # encoding was.
56
+ def text(s)
57
+ s.to_s.dup.force_encoding(Encoding::UTF_8)
58
+ end
18
59
  end
19
60
  end
20
61
 
@@ -62,7 +103,10 @@ module Asterism
62
103
  end
63
104
  end
64
105
 
65
- def self.error_reply(klass, message)
66
- ::MessagePack.pack(["error", klass.to_s, message.to_s], compatibility_mode: true)
106
+ # The calls being answered, per thread: an exposed method runs on the
107
+ # thread that answers (the receiving thread, or whatever polls), and
108
+ # Asterism.current_call on another thread is not inside that call.
109
+ def self.call_stack
110
+ Thread.current[:asterism_call_stack] ||= []
67
111
  end
68
112
  end
@@ -1,6 +1,7 @@
1
1
  module Asterism
2
2
  # The gem's version (the CRuby gem asterism). The Zenoh binding it needs
3
- # is in asterism.gemspec (asterism-zenoh ~> 0.4.0 for 0.5.0 and 0.6.0:
4
- # the bindings did not change; see CHANGELOG.md).
5
- VERSION = "0.6.0"
3
+ # is in asterism.gemspec (asterism-zenoh ~> 0.4.0 for 0.5.0 to 0.7.0:
4
+ # the bindings did not change; see CHANGELOG.md). 0.7.0 speaks wire
5
+ # protocol 1 only (Asterism::PROTOCOL, docs/protocol.md).
6
+ VERSION = "0.7.1"
6
7
  end
data/lib/asterism.rb CHANGED
@@ -18,6 +18,6 @@ end
18
18
  require_relative "asterism/cruby"
19
19
  # The CRuby API on top (blocks, a receiving thread, Enumerators,
20
20
  # pattern matching): CRuby only, see README "The CRuby API".
21
- %w[runner zenoh objects ros].each do |f|
21
+ %w[runner credentials zenoh objects ros].each do |f|
22
22
  require_relative "asterism/cruby/#{f}"
23
23
  end
data/mrblib/asterism.rb CHANGED
@@ -7,20 +7,25 @@
7
7
  # # Asterism["...", timeout: 5.0])
8
8
  # Asterism.poll # call regularly (answers incoming calls)
9
9
  #
10
- # Keys (doc/ruby_asterism/design.md ch. 4):
11
- # asterism/<node>/<app>/<object>/call get: payload [method, args, kwargs]
12
- # reply ["ok", value] or
13
- # ["error", class name, message]
14
- # asterism/<node>/<app>/<object>/meta get: reply {"methods" => [[name, arity], ...]}
15
- # asterism/<node> liveliness: the node is up
16
- # asterism/<node>/<app>/<object> liveliness: the object is exposed
17
- # asterism/<node>/@profile get: reply the node's profile (profile.rb)
10
+ # Keys and payloads: protocol 1 (docs/protocol.md):
11
+ # asterism/<node>/<app>/<object>/call/<caller>
12
+ # get: {"method" => name, "args" => [...], "kwargs" => {...}}
13
+ # reply {"value" => v} or
14
+ # {"error" => {"kind" => k, "class" => c, "message" => m}}
15
+ # asterism/<node>/<app>/<object>/meta
16
+ # get: reply {"methods" => [{"name" => n, "arity" => a}, ...]}
17
+ # asterism/<node>/<app> liveliness: the application is up
18
+ # asterism/<node>/<app>/<object> liveliness: the object is exposed
19
+ # asterism/<node>/@profile get: reply the node's profile (profile.rb)
18
20
  #
19
21
  # Everything is polled: nothing runs behind the application's back. A call
20
22
  # that waits for its answer keeps polling meanwhile and answers the calls
21
23
  # that come in, so two machines calling each other do not lock up.
22
24
  module Asterism
23
25
  ROOT = "asterism"
26
+ # The version of the wire protocol this implementation speaks (and the
27
+ # only one: protocol 0, the 0.x releases before 0.7.0, is not spoken).
28
+ PROTOCOL = 1
24
29
  # The time limit of a call (Asterism[path] without timeout:), in seconds
25
30
  # and in milliseconds.
26
31
  DEFAULT_TIMEOUT = 2.0
@@ -51,17 +56,49 @@ module Asterism
51
56
  class Disconnected < Error; end
52
57
 
53
58
  # The remote method raised, or could not be called (not exposed, wrong
54
- # number of arguments). remote_class is the class name on the other side.
59
+ # number of arguments). remote_class is the class name on the other side,
60
+ # remote_kind what happened, as a protocol word (a String): "raised" (the
61
+ # method raised; remote_class is then the callee language's own name),
62
+ # "not_exposed", "no_object", "arguments", "malformed", "unsendable",
63
+ # "nesting", "protocol" (docs/protocol.md 4.4); nil when the answer did
64
+ # not say.
55
65
  class RemoteError < Error
56
- attr_reader :remote_class, :remote_message
66
+ attr_reader :remote_class, :remote_message, :remote_kind
57
67
 
58
- def initialize(remote_class, remote_message)
68
+ def initialize(remote_class, remote_message, remote_kind = nil)
59
69
  @remote_class = remote_class.to_s
60
70
  @remote_message = remote_message.to_s
71
+ @remote_kind = remote_kind.nil? ? nil : remote_kind.to_s
61
72
  super("#{@remote_class}: #{@remote_message}")
62
73
  end
63
74
  end
64
75
 
76
+ # What Asterism.current_call returns while an exposed method runs for a
77
+ # call: who called and what. from is the calling node's ID (a String),
78
+ # verified how far that is checked: :claimed (the caller named itself in
79
+ # the call's key; a router's access control is what makes the name
80
+ # binding, docs/protocol.md 4.7) or :local (a call from this same
81
+ # application, which does not go on the network). object is the exposed
82
+ # object's name, method the method's name (Strings).
83
+ class CurrentCall
84
+ attr_reader :from, :verified, :object, :method
85
+
86
+ def initialize(from, verified, object, method)
87
+ @from = from
88
+ @verified = verified
89
+ @object = object
90
+ @method = method
91
+ end
92
+
93
+ def inspect
94
+ "#<Asterism::CurrentCall #{@object}.#{@method} from #{@from} (#{@verified})>"
95
+ end
96
+
97
+ def to_s
98
+ inspect
99
+ end
100
+ end
101
+
65
102
  # The old names of renamed constants: Asterism::Timeout (deprecated) is
66
103
  # TimeoutError. A constant alias would shadow Ruby's Timeout module inside
67
104
  # `module Asterism`, and cannot warn.
@@ -79,6 +116,9 @@ module Asterism
79
116
  MAX_DEPTH = 16
80
117
 
81
118
  # The value as it will be sent (Symbols become Strings), or EncodeError.
119
+ # A String goes as a MessagePack str, a binary String (String#b) as a
120
+ # bin: the MessagePack module decides by the String's encoding
121
+ # (ASCII-8BIT on CRuby, the binary flag on mruby).
82
122
  def self.check(v, depth = 0)
83
123
  raise ::Asterism::EncodeError, "value nested too deep (max #{MAX_DEPTH})" if depth > MAX_DEPTH
84
124
  if v.nil? || v == true || v == false || v.is_a?(Integer) || v.is_a?(Float) || v.is_a?(String)
@@ -101,7 +141,15 @@ module Asterism
101
141
  ::MessagePack.pack(check(v))
102
142
  end
103
143
 
104
- # nil when the bytes are not MessagePack.
144
+ # A String for a text field of the protocol (an error's class and
145
+ # message): always a str, also when it came as a binary String.
146
+ def self.text(s)
147
+ "#{s}"
148
+ end
149
+
150
+ # nil when the bytes are not exactly one MessagePack value of the types
151
+ # above (the MessagePack module refuses trailing bytes, ext types and
152
+ # integers outside int64).
105
153
  def self.unpack(bytes)
106
154
  return nil if bytes.nil? || bytes.empty?
107
155
  ::MessagePack.unpack(bytes)
@@ -219,7 +267,7 @@ module Asterism
219
267
  s.close
220
268
  raise Error, "#{node}/#{app} is already on the network"
221
269
  end
222
- @node_token = s.liveliness("#{ROOT}/#{node}")
270
+ @app_token = s.liveliness("#{ROOT}/#{node}/#{app}")
223
271
  @queryable = s.queryable("#{ROOT}/#{node}/#{app}/**", depth: 32)
224
272
  @watch = s.liveliness_watch("#{ROOT}/**", depth: watch_depth)
225
273
  @profile_queryable = s.queryable("#{ROOT}/#{node}/#{Profile::KEY}", depth: 4)
@@ -231,9 +279,9 @@ module Asterism
231
279
  @session = s
232
280
  @node = node
233
281
  @app = app
234
- @objects = {} # name => [object, {method name => arity}, token]
282
+ @objects = {} # name => [object, {method name => arity}, token, caller keyword or nil]
235
283
  @alive = {} # "node/app/object" => node (from liveliness)
236
- @nodes = {} # node => true (from liveliness)
284
+ @apps = {} # "node/app" => node (from liveliness)
237
285
  @proxies = {}
238
286
  @depth = 0
239
287
  @lost = nil
@@ -241,12 +289,13 @@ module Asterism
241
289
  @known_set = {} # the same, as node => true
242
290
  @node_list = [node] # what nodes returns (this node first), rebuilt on a change
243
291
  @nodes_changed = false # liveliness changed since tell_nodes last compared
292
+ @was_reconnecting = false
244
293
  tell_profile # for those who follow the profiles (the reply is the queryable's)
245
294
  self
246
295
  end
247
296
 
248
- # Whether objects of node/app are alive already (a second copy of the
249
- # same application). Waits for the answer (a router answers at once), at
297
+ # Whether node/app is alive already (its application token or an object:
298
+ # a second copy of the same application). Waits for the answer (a router answers at once), at
250
299
  # most check_ms. @api private
251
300
  def self.already_there?(s, node, app, check_ms = 1500)
252
301
  get_ms = check_ms > 1000 ? check_ms - 500 : check_ms
@@ -272,6 +321,20 @@ module Asterism
272
321
  true
273
322
  end
274
323
 
324
+ # True while the connection is lost and the Zenoh binding opens it again
325
+ # by itself (a client of a router: zenoh-pico built with reconnection on
326
+ # the boards, zenoh-c on CRuby). connected? stays true meanwhile, and once
327
+ # the router is back everything declared before is there again: the
328
+ # application token, the exposed objects, the liveliness watch, and what
329
+ # the application declared on Asterism.session. Meanwhile the other nodes
330
+ # leave (on_leave) and join again after it, calls raise Disconnected or
331
+ # time out, and puts reach nobody.
332
+ def self.reconnecting?
333
+ s = @session
334
+ return false if s.nil?
335
+ s.respond_to?(:reconnecting?) ? s.reconnecting? : false
336
+ end
337
+
275
338
  def self.node_id
276
339
  @node
277
340
  end
@@ -301,7 +364,7 @@ module Asterism
301
364
  @session = nil
302
365
  @objects = {}
303
366
  @alive = {}
304
- @nodes = {}
367
+ @apps = {}
305
368
  @node_list = nil
306
369
  @nodes_changed = true
307
370
  @proxies = {}
@@ -313,7 +376,7 @@ module Asterism
313
376
  @releasing = nil
314
377
  end
315
378
  end
316
- @node_token = nil
379
+ @app_token = nil
317
380
  @queryable = nil
318
381
  @watch = nil
319
382
  @profile_queryable = nil
@@ -376,7 +439,11 @@ module Asterism
376
439
  # Makes obj callable from other machines as <node>/<app>/<name>. Only the
377
440
  # listed methods can be called. methods: is an Array of names, or a Hash
378
441
  # name => number of arguments (checked before calling; -1 for any).
379
- def self.expose(name, obj, methods:)
442
+ # caller: a keyword name (caller: :from): each call passes the calling
443
+ # node's ID (Asterism.current_call.from) to the method as that keyword,
444
+ # and a call that brings the keyword itself is refused (an "arguments"
445
+ # error), so the value is always the one Asterism took from the call.
446
+ def self.expose(name, obj, methods:, caller: nil)
380
447
  s = session!
381
448
  name = check_name("object", name)
382
449
  table = {}
@@ -389,9 +456,17 @@ module Asterism
389
456
  table.each_key do |m|
390
457
  raise ArgumentError, "#{obj.class} has no public method #{m}" unless obj.respond_to?(m)
391
458
  end
459
+ kw = nil
460
+ unless caller.nil?
461
+ unless caller.is_a?(Symbol) || caller.is_a?(String)
462
+ raise ArgumentError, "caller: expects a keyword name (caller: :from), got #{caller.inspect}"
463
+ end
464
+ kw = caller.to_s
465
+ raise ArgumentError, "caller: expects a keyword name (caller: :from), got #{caller.inspect}" if kw.empty?
466
+ end
392
467
  unexpose(name)
393
468
  token = s.liveliness("#{ROOT}/#{@node}/#{@app}/#{name}")
394
- @objects[name] = [obj, table, token]
469
+ @objects[name] = [obj, table, token, kw]
395
470
  "#{@node}/#{@app}/#{name}"
396
471
  rescue Asterism::Zenoh::Error => e
397
472
  raise Disconnected, e.message
@@ -424,7 +499,7 @@ module Asterism
424
499
 
425
500
  # ------------------------------------------------------------ nodes coming and going
426
501
 
427
- # on_join { |node| }: another node appeared (its node token or one of its
502
+ # on_join { |node| }: another node appeared (one of its applications or
428
503
  # objects). The nodes there already join on the first polls after
429
504
  # connecting. on_leave { |node| }: a node is gone (no token and no object
430
505
  # left); when the connection is lost, every node known until then leaves.
@@ -534,19 +609,43 @@ module Asterism
534
609
  lost("the connection was lost")
535
610
  return false
536
611
  end
612
+ back_again(s)
537
613
  answer_calls
538
614
  answer_profile
539
615
  follow_liveliness
540
616
  !@session.nil?
541
617
  rescue Asterism::Zenoh::Error => e
542
- lost(e.message)
543
- false
618
+ lost_if_closed(e.message)
619
+ !@session.nil?
620
+ end
621
+
622
+ # After the binding opened a lost connection again: tell the profile
623
+ # again (a put made while the router was away reached nobody).
624
+ # @api private
625
+ def self.back_again(s)
626
+ now = s.respond_to?(:reconnecting?) && s.reconnecting?
627
+ was = @was_reconnecting
628
+ @was_reconnecting = now
629
+ tell_profile if was && !now
630
+ end
631
+
632
+ # A Zenoh error during a call or a poll: the connection is gone only when
633
+ # the session says it is closed. While the binding reconnects, the
634
+ # session (and everything declared on it) stays.
635
+ # @api private
636
+ def self.lost_if_closed(reason)
637
+ s = @session
638
+ lost(reason) if s.nil? || s.closed?
544
639
  end
545
640
 
546
641
  # Polls until the block is true. Answers incoming calls meanwhile.
547
642
  # @api private
548
643
  def self.wait_until
549
- raise Error, "calls nested too deep (max #{MAX_NESTING})" if (@depth || 0) >= MAX_NESTING
644
+ if (@depth || 0) >= MAX_NESTING
645
+ # Kept, so that a method that lets it out is answered as "nesting".
646
+ @nesting_error = Error.new("calls nested too deep (max #{MAX_NESTING})")
647
+ raise @nesting_error
648
+ end
550
649
  @depth = (@depth || 0) + 1
551
650
  begin
552
651
  until yield
@@ -592,13 +691,13 @@ module Asterism
592
691
  i += 1
593
692
  rest = key[ROOT.length + 1, key.length].to_s
594
693
  parts = rest.split("/")
595
- if parts.size == 1
694
+ if parts.size == 2 # an application: asterism/<node>/<app>
596
695
  if alive
597
- @nodes[rest] = true
696
+ @apps[rest] = parts[0]
598
697
  else
599
- @nodes.delete(rest)
698
+ @apps.delete(rest)
600
699
  end
601
- elsif parts.size == 3
700
+ elsif parts.size == 3 # an object: asterism/<node>/<app>/<object>
602
701
  if alive
603
702
  @alive[rest] = parts[0]
604
703
  else
@@ -622,7 +721,7 @@ module Asterism
622
721
  return list if list
623
722
  list = [@node]
624
723
  seen = { @node => true }
625
- names = @nodes.keys
724
+ names = @apps.values
626
725
  i = 0
627
726
  while i < names.size
628
727
  n = names[i]
@@ -689,39 +788,51 @@ module Asterism
689
788
  # @api private
690
789
  def self.call_async(path, method, args, kwargs, timeout_ms)
691
790
  parts = split_path(path)
692
- payload = Codec.pack([method.to_s, args, kwargs])
791
+ payload = Codec.pack(request(method, args, kwargs))
693
792
  if parts[0] == @node && parts[1] == @app && @session
694
793
  # This application's own object: no network (a session does not see
695
794
  # its own queryable), same encoding and checks as a remote call.
696
- return Future.answered(path, method, dispatch(parts[2], payload))
795
+ return Future.answered(path, method, dispatch(parts[2], payload, @node, :local))
697
796
  end
698
797
  begin
699
- g = session!.get("#{ROOT}/#{path}/call", timeout_ms: timeout_ms, payload: payload)
798
+ s = session!
799
+ # The caller's node ID is the key's last chunk (docs/protocol.md 4.1).
800
+ g = s.get("#{ROOT}/#{path}/call/#{@node}", timeout_ms: timeout_ms, payload: payload)
700
801
  rescue Asterism::Zenoh::Error => e
701
- lost(e.message)
802
+ lost_if_closed(e.message)
702
803
  raise Disconnected, e.message
703
804
  end
704
805
  Future.new(path, method, g, now_ms + timeout_ms, timeout_ms)
705
806
  end
706
807
 
808
+ # The request of a call: a map with the method's name, and the arguments
809
+ # and keyword arguments when there are any (absent means none).
810
+ # @api private
811
+ def self.request(method, args, kwargs)
812
+ req = { "method" => method.to_s }
813
+ req["args"] = args unless args.nil? || args.empty?
814
+ req["kwargs"] = kwargs unless kwargs.nil? || kwargs.empty?
815
+ req
816
+ end
817
+
707
818
  # @api private
708
819
  def self.call(path, method, args, kwargs, timeout_ms)
709
820
  call_async(path, method, args, kwargs, timeout_ms).value
710
821
  end
711
822
 
712
- # The meta reply of path: {"methods" => [[name, arity], ...]}.
823
+ # The meta reply of path: {"methods" => [{"name" => n, "arity" => a}, ...]}.
713
824
  # @api private
714
825
  def self.meta(path, timeout_ms = DEFAULT_TIMEOUT_MS)
715
826
  parts = split_path(path)
716
827
  if parts[0] == @node && parts[1] == @app && @session
717
828
  m = meta_of(parts[2])
718
- raise RemoteError.new("NameError", "no object #{parts[2]} exposed by #{@node}/#{@app}") unless m
829
+ raise RemoteError.new("NameError", "no object #{parts[2]} exposed by #{@node}/#{@app}", "no_object") unless m
719
830
  return m
720
831
  end
721
832
  begin
722
833
  g = session!.get("#{ROOT}/#{path}/meta", timeout_ms: timeout_ms)
723
834
  rescue Asterism::Zenoh::Error => e
724
- lost(e.message)
835
+ lost_if_closed(e.message)
725
836
  raise Disconnected, e.message
726
837
  end
727
838
  f = Future.new(path, "meta", g, now_ms + timeout_ms, timeout_ms)
@@ -730,6 +841,21 @@ module Asterism
730
841
 
731
842
  # ------------------------------------------------------------ answering
732
843
 
844
+ # The call being answered now (an Asterism::CurrentCall: who called,
845
+ # which object and method), while an exposed method runs for a call; nil
846
+ # outside one. Calls answered while that method waits for its own call
847
+ # are on top of it until they end.
848
+ def self.current_call
849
+ stack = call_stack
850
+ stack.empty? ? nil : stack[stack.size - 1]
851
+ end
852
+
853
+ # The calls being answered, innermost last (CRuby: per thread).
854
+ # @api private
855
+ def self.call_stack
856
+ @call_stack ||= []
857
+ end
858
+
733
859
  # @api private
734
860
  def self.answer_calls
735
861
  qa = @queryable
@@ -753,62 +879,121 @@ module Asterism
753
879
  end
754
880
  end
755
881
 
756
- # asterism/<node>/<app>/<object>/<call|meta>; the node and app may come
757
- # as wildcards (the query matched this application's queryable).
882
+ # asterism/<node>/<app>/<object>/call/<caller> and
883
+ # asterism/<node>/<app>/<object>/meta; the node and app may come as
884
+ # wildcards (the query matched this application's queryable).
758
885
  # @api private
759
886
  def self.answer(q)
760
887
  parts = q.key.split("/")
761
- return unless parts.size == 5
888
+ n = parts.size
889
+ return unless n == 5 || n == 6
762
890
  obj = parts[3]
763
891
  kind = parts[4]
764
892
  prefix = "#{ROOT}/#{@node}/#{@app}/"
765
893
  if kind == "meta"
894
+ return unless n == 5
766
895
  names = obj == "*" ? @objects.keys : [obj]
767
- names.each do |n|
768
- m = meta_of(n)
769
- q.reply("#{prefix}#{n}/meta", Codec.pack(m)) if m
896
+ names.each do |nm|
897
+ m = meta_of(nm)
898
+ q.reply("#{prefix}#{nm}/meta", Codec.pack(m)) if m
899
+ end
900
+ elsif kind == "call" && valid_name?(obj)
901
+ if n == 5
902
+ # A protocol 0 call (no caller chunk): refused, so that its sender
903
+ # learns why at once instead of waiting.
904
+ q.reply("#{prefix}#{obj}/call",
905
+ error_reply("protocol", "Asterism::Error",
906
+ "#{@node}/#{@app} speaks Asterism protocol #{PROTOCOL}: call " \
907
+ "asterism/<node>/<app>/<object>/call/<caller> with a map " \
908
+ "{\"method\", \"args\", \"kwargs\"} (asterism 0.7.0 and later)"))
909
+ elsif valid_name?(parts[5])
910
+ q.reply("#{prefix}#{obj}/call/#{parts[5]}", dispatch(obj, q.payload, parts[5], :claimed))
770
911
  end
771
- elsif kind == "call" && obj != "*"
772
- q.reply("#{prefix}#{obj}/call", dispatch(obj, q.payload))
773
912
  end
774
913
  end
775
914
 
915
+ # Whether s can be a node, app or object name (check_name without raising).
916
+ # @api private
917
+ def self.valid_name?(s)
918
+ return false if s.nil? || s.empty? || s.start_with?("@")
919
+ !(s.include?("/") || s.include?("*") || s.include?("$") || s.include?("?") || s.include?("#"))
920
+ end
921
+
776
922
  # @api private
777
923
  def self.meta_of(name)
778
924
  entry = @objects[name]
779
925
  return nil unless entry
780
926
  list = []
781
- entry[1].each { |m, n| list << [m, n] }
927
+ entry[1].each { |m, n| list << { "name" => m, "arity" => n } }
782
928
  { "methods" => list }
783
929
  end
784
930
 
785
- # Runs a call on an exposed object; returns the packed reply.
931
+ MALFORMED = "malformed call (expected a map {\"method\" => name, \"args\" => [...], \"kwargs\" => {...}})"
932
+
933
+ # Runs a call on an exposed object; returns the packed reply. from is
934
+ # the caller's node ID, verified how it is known (CurrentCall).
786
935
  # @api private
787
- def self.dispatch(obj_name, payload)
788
- req = Codec.unpack(payload)
789
- unless req.is_a?(Array) && req.size == 3 && req[0].is_a?(String) && req[1].is_a?(Array) && req[2].is_a?(Hash)
790
- return error_reply("ArgumentError", "malformed call (expected [method, args, kwargs])")
791
- end
936
+ def self.dispatch(obj_name, payload, from, verified)
937
+ req = parse_request(Codec.unpack(payload))
938
+ return error_reply("malformed", "ArgumentError", MALFORMED) unless req
792
939
  entry = @objects[obj_name]
793
- return error_reply("NameError", "no object #{obj_name} exposed by #{@node}/#{@app}") unless entry
940
+ return error_reply("no_object", "NameError", "no object #{obj_name} exposed by #{@node}/#{@app}") unless entry
794
941
  name = req[0]
795
942
  args = req[1]
796
943
  kw = req[2]
797
944
  arity = entry[1][name]
798
945
  if arity.nil?
799
- return error_reply("NoMethodError", "undefined method '#{name}' for #{@node}/#{@app}/#{obj_name} (not exposed)")
946
+ return error_reply("not_exposed", "NoMethodError",
947
+ "undefined method '#{name}' for #{@node}/#{@app}/#{obj_name} (not exposed)")
800
948
  end
801
949
  if arity >= 0 && args.size != arity
802
- return error_reply("ArgumentError", "wrong number of arguments (given #{args.size}, expected #{arity})")
950
+ return error_reply("arguments", "ArgumentError", "wrong number of arguments (given #{args.size}, expected #{arity})")
951
+ end
952
+ ckw = entry[3]
953
+ if ckw
954
+ if kw.key?(ckw)
955
+ return error_reply("arguments", "ArgumentError",
956
+ "#{ckw}: is given by Asterism, not by the caller " \
957
+ "(#{@node}/#{@app}/#{obj_name} is exposed with caller: :#{ckw})")
958
+ end
959
+ kw[ckw] = from
803
960
  end
961
+ stack = call_stack
962
+ stack << CurrentCall.new(from, verified, obj_name, name)
804
963
  begin
805
964
  value = invoke(entry[0], name, args, kw)
806
- Codec.pack(["ok", value])
807
- rescue EncodeError => e
808
- error_reply("Asterism::EncodeError", "the return value: #{e.message}")
809
965
  rescue => e
810
- error_reply(e.class.to_s, e.message)
966
+ return error_reply(e.equal?(@nesting_error) ? "nesting" : "raised", e.class.to_s, e.message)
967
+ ensure
968
+ stack.pop
969
+ end
970
+ begin
971
+ Codec.pack({ "value" => value })
972
+ rescue EncodeError => e
973
+ error_reply("unsendable", "Asterism::EncodeError", "the return value: #{e.message}")
974
+ end
975
+ end
976
+
977
+ # [method, args, kwargs] of a decoded request, or nil when it is not one:
978
+ # a map with "method" (a non-empty str), "args" (an array, absent: none)
979
+ # and "kwargs" (a map with str keys, absent: none). Other keys are
980
+ # ignored (room for later additions).
981
+ # @api private
982
+ def self.parse_request(req)
983
+ return nil unless req.is_a?(Hash)
984
+ m = req["method"]
985
+ return nil unless m.is_a?(String) && !m.empty?
986
+ args = req.key?("args") ? req["args"] : []
987
+ return nil unless args.is_a?(Array)
988
+ kw = req.key?("kwargs") ? req["kwargs"] : {}
989
+ return nil unless kw.is_a?(Hash)
990
+ keys = kw.keys
991
+ i = 0
992
+ while i < keys.size
993
+ return nil unless keys[i].is_a?(String)
994
+ i += 1
811
995
  end
996
+ [m, args, kw]
812
997
  end
813
998
 
814
999
  # @api private
@@ -817,13 +1002,14 @@ module Asterism
817
1002
  obj.public_send(name, *args)
818
1003
  else
819
1004
  opts = {}
820
- kw.each { |k, v| opts[k.to_s.to_sym] = v }
1005
+ kw.each { |k, v| opts[k.to_sym] = v }
821
1006
  obj.public_send(name, *args, **opts)
822
1007
  end
823
1008
  end
824
1009
 
1010
+ # {"error" => {"kind", "class", "message"}}, packed.
825
1011
  # @api private
826
- def self.error_reply(klass, message)
827
- ::MessagePack.pack(["error", klass.to_s, message.to_s])
1012
+ def self.error_reply(kind, klass, message)
1013
+ ::MessagePack.pack({ "error" => { "kind" => kind, "class" => Codec.text(klass), "message" => Codec.text(message) } })
828
1014
  end
829
1015
  end