asterism 0.5.0 → 0.7.0

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.
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)
@@ -227,22 +275,26 @@ module Asterism
227
275
  s.close
228
276
  raise Disconnected, e.message
229
277
  end
278
+ s.extend(SharedSession) # Asterism.session: the application's close does not close it
230
279
  @session = s
231
280
  @node = node
232
281
  @app = app
233
- @objects = {} # name => [object, {method name => arity}, token]
234
- @alive = {} # "node/app/object" => true (from liveliness)
235
- @nodes = {} # node => true (from liveliness)
282
+ @objects = {} # name => [object, {method name => arity}, token, caller keyword or nil]
283
+ @alive = {} # "node/app/object" => node (from liveliness)
284
+ @apps = {} # "node/app" => node (from liveliness)
236
285
  @proxies = {}
237
286
  @depth = 0
238
287
  @lost = nil
239
288
  @known = [] # the other nodes told to on_join so far
289
+ @known_set = {} # the same, as node => true
290
+ @node_list = [node] # what nodes returns (this node first), rebuilt on a change
291
+ @nodes_changed = false # liveliness changed since tell_nodes last compared
240
292
  tell_profile # for those who follow the profiles (the reply is the queryable's)
241
293
  self
242
294
  end
243
295
 
244
- # Whether objects of node/app are alive already (a second copy of the
245
- # same application). Waits for the answer (a router answers at once), at
296
+ # Whether node/app is alive already (its application token or an object:
297
+ # a second copy of the same application). Waits for the answer (a router answers at once), at
246
298
  # most check_ms. @api private
247
299
  def self.already_there?(s, node, app, check_ms = 1500)
248
300
  get_ms = check_ms > 1000 ? check_ms - 500 : check_ms
@@ -297,21 +349,68 @@ module Asterism
297
349
  @session = nil
298
350
  @objects = {}
299
351
  @alive = {}
300
- @nodes = {}
352
+ @apps = {}
353
+ @node_list = nil
354
+ @nodes_changed = true
301
355
  @proxies = {}
302
- s.close if s
303
- @node_token = nil
356
+ if s
357
+ @releasing = s
358
+ begin
359
+ s.close
360
+ ensure
361
+ @releasing = nil
362
+ end
363
+ end
364
+ @app_token = nil
304
365
  @queryable = nil
305
366
  @watch = nil
306
367
  @profile_queryable = nil
307
368
  end
308
369
 
309
- # @api private
370
+ # The Zenoh session of the connection (an Asterism::Zenoh::Session), for
371
+ # what the object layer does not do: put and subscribe, a ROS 2 node
372
+ # (Asterism::ROS::Node.new(Asterism.session, ...)), anything of the
373
+ # Zenoh API. One connection for everything: on a router that lets each
374
+ # pass in once, a second session would not get in, and a ROS 2 node on
375
+ # this session is part of this node (the same Zenoh ID).
376
+ #
377
+ # The session stays Asterism's: its close does nothing (it warns once);
378
+ # Asterism.close closes it, and with it everything declared on it (the
379
+ # application's subscribers, publishers, ROS 2 nodes). After a lost
380
+ # connection and a new Asterism.connect, take Asterism.session again and
381
+ # declare those again. Polling it (session.poll, node.poll) is fine:
382
+ # Asterism.poll polls it too, each entity keeps what came for it.
383
+ # Raises Disconnected when not connected (as the calls do).
384
+ def self.session
385
+ raise Disconnected, (@lost || "not connected") unless connected?
386
+ @session
387
+ end
388
+
389
+ # @api private (the name before 0.6.0; Asterism.session is the same)
310
390
  def self.session!
311
391
  raise Disconnected, (@lost || "not connected") unless @session
312
392
  @session
313
393
  end
314
394
 
395
+ # Whether Asterism itself is closing s (SharedSession#close).
396
+ # @api private
397
+ def self.releasing?(s)
398
+ !@releasing.nil? && @releasing.equal?(s)
399
+ end
400
+
401
+ # Extended onto the session of Asterism.connect: the session belongs to
402
+ # Asterism, so a close from anyone else (the application, a library that
403
+ # was handed Asterism.session) does nothing. Asterism.close and a lost
404
+ # connection close it.
405
+ module SharedSession
406
+ def close
407
+ return super if ::Asterism.releasing?(self)
408
+ ::Asterism.warn_once(self, "asterism: Asterism.session belongs to Asterism; its close is ignored " \
409
+ "(Asterism.close closes it)")
410
+ nil
411
+ end
412
+ end
413
+
315
414
  # The connection went away: closed, but the next Asterism.poll still tells
316
415
  # on_leave about every node known until then.
317
416
  # @api private
@@ -325,7 +424,11 @@ module Asterism
325
424
  # Makes obj callable from other machines as <node>/<app>/<name>. Only the
326
425
  # listed methods can be called. methods: is an Array of names, or a Hash
327
426
  # name => number of arguments (checked before calling; -1 for any).
328
- def self.expose(name, obj, methods:)
427
+ # caller: a keyword name (caller: :from): each call passes the calling
428
+ # node's ID (Asterism.current_call.from) to the method as that keyword,
429
+ # and a call that brings the keyword itself is refused (an "arguments"
430
+ # error), so the value is always the one Asterism took from the call.
431
+ def self.expose(name, obj, methods:, caller: nil)
329
432
  s = session!
330
433
  name = check_name("object", name)
331
434
  table = {}
@@ -338,9 +441,17 @@ module Asterism
338
441
  table.each_key do |m|
339
442
  raise ArgumentError, "#{obj.class} has no public method #{m}" unless obj.respond_to?(m)
340
443
  end
444
+ kw = nil
445
+ unless caller.nil?
446
+ unless caller.is_a?(Symbol) || caller.is_a?(String)
447
+ raise ArgumentError, "caller: expects a keyword name (caller: :from), got #{caller.inspect}"
448
+ end
449
+ kw = caller.to_s
450
+ raise ArgumentError, "caller: expects a keyword name (caller: :from), got #{caller.inspect}" if kw.empty?
451
+ end
341
452
  unexpose(name)
342
453
  token = s.liveliness("#{ROOT}/#{@node}/#{@app}/#{name}")
343
- @objects[name] = [obj, table, token]
454
+ @objects[name] = [obj, table, token, kw]
344
455
  "#{@node}/#{@app}/#{name}"
345
456
  rescue Asterism::Zenoh::Error => e
346
457
  raise Disconnected, e.message
@@ -361,6 +472,10 @@ module Asterism
361
472
  # Call from the application's update loop. Answers the calls that came
362
473
  # in, follows who is alive and calls the on_join / on_leave blocks. false
363
474
  # once the connection is closed.
475
+ #
476
+ # Cheap when nothing came: each queue is looked at by its count first,
477
+ # and the list of nodes (and the comparison behind on_join / on_leave)
478
+ # is made again only after a liveliness change.
364
479
  def self.poll
365
480
  ok = @session ? pump : false
366
481
  tell_nodes
@@ -369,7 +484,7 @@ module Asterism
369
484
 
370
485
  # ------------------------------------------------------------ nodes coming and going
371
486
 
372
- # on_join { |node| }: another node appeared (its node token or one of its
487
+ # on_join { |node| }: another node appeared (one of its applications or
373
488
  # objects). The nodes there already join on the first polls after
374
489
  # connecting. on_leave { |node| }: a node is gone (no token and no object
375
490
  # left); when the connection is lost, every node known until then leaves.
@@ -408,14 +523,43 @@ module Asterism
408
523
  def self.tell_nodes
409
524
  known = @known
410
525
  return if known.nil?
411
- now = @session ? nodes : []
412
- now.shift if @session # this node
526
+ # Nothing came on the liveliness watch since the last comparison (and
527
+ # the connection is neither new nor lost): nothing can have changed.
528
+ return unless @nodes_changed
529
+ @nodes_changed = false
530
+ now = []
531
+ now_set = {}
532
+ if @session
533
+ list = node_list
534
+ i = 1 # list[0] is this node
535
+ while i < list.size
536
+ n = list[i]
537
+ now << n
538
+ now_set[n] = true
539
+ i += 1
540
+ end
541
+ end
542
+ known_set = @known_set || {}
413
543
  joined = []
414
544
  left = []
415
- now.each { |n| joined << n unless known.include?(n) }
416
- known.each { |n| left << n unless now.include?(n) }
545
+ i = 0
546
+ while i < now.size
547
+ joined << now[i] unless known_set[now[i]]
548
+ i += 1
549
+ end
550
+ i = 0
551
+ while i < known.size
552
+ left << known[i] unless now_set[known[i]]
553
+ i += 1
554
+ end
417
555
  # After a loss, everything has been told: nothing more to compare.
418
- @known = @session ? now : nil
556
+ if @session
557
+ @known = now
558
+ @known_set = now_set
559
+ else
560
+ @known = nil
561
+ @known_set = nil
562
+ end
419
563
  tell_each("on_join", @on_join, joined)
420
564
  tell_each("on_leave", @on_leave, left)
421
565
  end
@@ -462,7 +606,11 @@ module Asterism
462
606
  # Polls until the block is true. Answers incoming calls meanwhile.
463
607
  # @api private
464
608
  def self.wait_until
465
- raise Error, "calls nested too deep (max #{MAX_NESTING})" if (@depth || 0) >= MAX_NESTING
609
+ if (@depth || 0) >= MAX_NESTING
610
+ # Kept, so that a method that lets it out is answered as "nesting".
611
+ @nesting_error = Error.new("calls nested too deep (max #{MAX_NESTING})")
612
+ raise @nesting_error
613
+ end
466
614
  @depth = (@depth || 0) + 1
467
615
  begin
468
616
  until yield
@@ -493,9 +641,10 @@ module Asterism
493
641
  # @api private
494
642
  def self.follow_liveliness
495
643
  w = @watch
496
- return unless w
644
+ return unless w && w.pending > 0
497
645
  # Array forms instead of blocks called from C (see Future#collect).
498
646
  events = w.each_pending
647
+ @nodes_changed = true if events.size > 0
499
648
  if events.size > 0 && w.dropped > 0
500
649
  warn_once(w, "asterism: #{w.dropped} liveliness changes dropped (more than #{watch_depth} at once); " \
501
650
  "Asterism.each and Asterism.nodes may miss some objects")
@@ -507,32 +656,57 @@ module Asterism
507
656
  i += 1
508
657
  rest = key[ROOT.length + 1, key.length].to_s
509
658
  parts = rest.split("/")
510
- if parts.size == 1
659
+ if parts.size == 2 # an application: asterism/<node>/<app>
511
660
  if alive
512
- @nodes[rest] = true
661
+ @apps[rest] = parts[0]
513
662
  else
514
- @nodes.delete(rest)
663
+ @apps.delete(rest)
515
664
  end
516
- elsif parts.size == 3
665
+ elsif parts.size == 3 # an object: asterism/<node>/<app>/<object>
517
666
  if alive
518
- @alive[rest] = true
667
+ @alive[rest] = parts[0]
519
668
  else
520
669
  @alive.delete(rest)
521
670
  end
522
671
  end
523
672
  end
673
+ @node_list = nil
524
674
  end
525
675
 
526
676
  # The node IDs alive now, this one first.
527
677
  def self.nodes
528
678
  return [] unless @session
679
+ node_list.dup
680
+ end
681
+
682
+ # The list behind nodes, made again only after a liveliness change (the
683
+ # caller must not change it). @api private
684
+ def self.node_list
685
+ list = @node_list
686
+ return list if list
529
687
  list = [@node]
530
- @nodes.each_key { |n| list << n unless list.include?(n) }
531
- @alive.each_key do |path|
532
- n = path.split("/")[0]
533
- list << n unless list.include?(n)
688
+ seen = { @node => true }
689
+ names = @apps.values
690
+ i = 0
691
+ while i < names.size
692
+ n = names[i]
693
+ unless seen[n]
694
+ seen[n] = true
695
+ list << n
696
+ end
697
+ i += 1
698
+ end
699
+ owners = @alive.values
700
+ i = 0
701
+ while i < owners.size
702
+ n = owners[i]
703
+ unless seen[n]
704
+ seen[n] = true
705
+ list << n
706
+ end
707
+ i += 1
534
708
  end
535
- list
709
+ @node_list = list
536
710
  end
537
711
 
538
712
  # The exposed objects alive now whose <node>/<app>/<object> matches the
@@ -543,11 +717,15 @@ module Asterism
543
717
  def self.each(pattern = "**")
544
718
  paths = []
545
719
  if @session
720
+ seen = {}
546
721
  @objects.each_key do |name|
547
722
  path = "#{@node}/#{@app}/#{name}"
548
- paths << path if glob?(pattern, path)
723
+ if glob?(pattern, path)
724
+ paths << path
725
+ seen[path] = true
726
+ end
549
727
  end
550
- @alive.each_key { |path| paths << path if glob?(pattern, path) && !paths.include?(path) }
728
+ @alive.each_key { |path| paths << path if !seen[path] && glob?(pattern, path) }
551
729
  end
552
730
  proxies = paths.map { |path| self[path] }
553
731
  return proxies unless block_given?
@@ -575,14 +753,16 @@ module Asterism
575
753
  # @api private
576
754
  def self.call_async(path, method, args, kwargs, timeout_ms)
577
755
  parts = split_path(path)
578
- payload = Codec.pack([method.to_s, args, kwargs])
756
+ payload = Codec.pack(request(method, args, kwargs))
579
757
  if parts[0] == @node && parts[1] == @app && @session
580
758
  # This application's own object: no network (a session does not see
581
759
  # its own queryable), same encoding and checks as a remote call.
582
- return Future.answered(path, method, dispatch(parts[2], payload))
760
+ return Future.answered(path, method, dispatch(parts[2], payload, @node, :local))
583
761
  end
584
762
  begin
585
- g = session!.get("#{ROOT}/#{path}/call", timeout_ms: timeout_ms, payload: payload)
763
+ s = session!
764
+ # The caller's node ID is the key's last chunk (docs/protocol.md 4.1).
765
+ g = s.get("#{ROOT}/#{path}/call/#{@node}", timeout_ms: timeout_ms, payload: payload)
586
766
  rescue Asterism::Zenoh::Error => e
587
767
  lost(e.message)
588
768
  raise Disconnected, e.message
@@ -590,18 +770,28 @@ module Asterism
590
770
  Future.new(path, method, g, now_ms + timeout_ms, timeout_ms)
591
771
  end
592
772
 
773
+ # The request of a call: a map with the method's name, and the arguments
774
+ # and keyword arguments when there are any (absent means none).
775
+ # @api private
776
+ def self.request(method, args, kwargs)
777
+ req = { "method" => method.to_s }
778
+ req["args"] = args unless args.nil? || args.empty?
779
+ req["kwargs"] = kwargs unless kwargs.nil? || kwargs.empty?
780
+ req
781
+ end
782
+
593
783
  # @api private
594
784
  def self.call(path, method, args, kwargs, timeout_ms)
595
785
  call_async(path, method, args, kwargs, timeout_ms).value
596
786
  end
597
787
 
598
- # The meta reply of path: {"methods" => [[name, arity], ...]}.
788
+ # The meta reply of path: {"methods" => [{"name" => n, "arity" => a}, ...]}.
599
789
  # @api private
600
790
  def self.meta(path, timeout_ms = DEFAULT_TIMEOUT_MS)
601
791
  parts = split_path(path)
602
792
  if parts[0] == @node && parts[1] == @app && @session
603
793
  m = meta_of(parts[2])
604
- raise RemoteError.new("NameError", "no object #{parts[2]} exposed by #{@node}/#{@app}") unless m
794
+ raise RemoteError.new("NameError", "no object #{parts[2]} exposed by #{@node}/#{@app}", "no_object") unless m
605
795
  return m
606
796
  end
607
797
  begin
@@ -616,10 +806,25 @@ module Asterism
616
806
 
617
807
  # ------------------------------------------------------------ answering
618
808
 
809
+ # The call being answered now (an Asterism::CurrentCall: who called,
810
+ # which object and method), while an exposed method runs for a call; nil
811
+ # outside one. Calls answered while that method waits for its own call
812
+ # are on top of it until they end.
813
+ def self.current_call
814
+ stack = call_stack
815
+ stack.empty? ? nil : stack[stack.size - 1]
816
+ end
817
+
818
+ # The calls being answered, innermost last (CRuby: per thread).
819
+ # @api private
820
+ def self.call_stack
821
+ @call_stack ||= []
822
+ end
823
+
619
824
  # @api private
620
825
  def self.answer_calls
621
826
  qa = @queryable
622
- return unless qa
827
+ return unless qa && qa.pending > 0
623
828
  # Without a block, so that a handler that waits (and so polls) does not
624
829
  # re-enter the queryable's iteration.
625
830
  queries = qa.each_pending
@@ -639,62 +844,121 @@ module Asterism
639
844
  end
640
845
  end
641
846
 
642
- # asterism/<node>/<app>/<object>/<call|meta>; the node and app may come
643
- # as wildcards (the query matched this application's queryable).
847
+ # asterism/<node>/<app>/<object>/call/<caller> and
848
+ # asterism/<node>/<app>/<object>/meta; the node and app may come as
849
+ # wildcards (the query matched this application's queryable).
644
850
  # @api private
645
851
  def self.answer(q)
646
852
  parts = q.key.split("/")
647
- return unless parts.size == 5
853
+ n = parts.size
854
+ return unless n == 5 || n == 6
648
855
  obj = parts[3]
649
856
  kind = parts[4]
650
857
  prefix = "#{ROOT}/#{@node}/#{@app}/"
651
858
  if kind == "meta"
859
+ return unless n == 5
652
860
  names = obj == "*" ? @objects.keys : [obj]
653
- names.each do |n|
654
- m = meta_of(n)
655
- q.reply("#{prefix}#{n}/meta", Codec.pack(m)) if m
861
+ names.each do |nm|
862
+ m = meta_of(nm)
863
+ q.reply("#{prefix}#{nm}/meta", Codec.pack(m)) if m
864
+ end
865
+ elsif kind == "call" && valid_name?(obj)
866
+ if n == 5
867
+ # A protocol 0 call (no caller chunk): refused, so that its sender
868
+ # learns why at once instead of waiting.
869
+ q.reply("#{prefix}#{obj}/call",
870
+ error_reply("protocol", "Asterism::Error",
871
+ "#{@node}/#{@app} speaks Asterism protocol #{PROTOCOL}: call " \
872
+ "asterism/<node>/<app>/<object>/call/<caller> with a map " \
873
+ "{\"method\", \"args\", \"kwargs\"} (asterism 0.7.0 and later)"))
874
+ elsif valid_name?(parts[5])
875
+ q.reply("#{prefix}#{obj}/call/#{parts[5]}", dispatch(obj, q.payload, parts[5], :claimed))
656
876
  end
657
- elsif kind == "call" && obj != "*"
658
- q.reply("#{prefix}#{obj}/call", dispatch(obj, q.payload))
659
877
  end
660
878
  end
661
879
 
880
+ # Whether s can be a node, app or object name (check_name without raising).
881
+ # @api private
882
+ def self.valid_name?(s)
883
+ return false if s.nil? || s.empty? || s.start_with?("@")
884
+ !(s.include?("/") || s.include?("*") || s.include?("$") || s.include?("?") || s.include?("#"))
885
+ end
886
+
662
887
  # @api private
663
888
  def self.meta_of(name)
664
889
  entry = @objects[name]
665
890
  return nil unless entry
666
891
  list = []
667
- entry[1].each { |m, n| list << [m, n] }
892
+ entry[1].each { |m, n| list << { "name" => m, "arity" => n } }
668
893
  { "methods" => list }
669
894
  end
670
895
 
671
- # Runs a call on an exposed object; returns the packed reply.
896
+ MALFORMED = "malformed call (expected a map {\"method\" => name, \"args\" => [...], \"kwargs\" => {...}})"
897
+
898
+ # Runs a call on an exposed object; returns the packed reply. from is
899
+ # the caller's node ID, verified how it is known (CurrentCall).
672
900
  # @api private
673
- def self.dispatch(obj_name, payload)
674
- req = Codec.unpack(payload)
675
- unless req.is_a?(Array) && req.size == 3 && req[0].is_a?(String) && req[1].is_a?(Array) && req[2].is_a?(Hash)
676
- return error_reply("ArgumentError", "malformed call (expected [method, args, kwargs])")
677
- end
901
+ def self.dispatch(obj_name, payload, from, verified)
902
+ req = parse_request(Codec.unpack(payload))
903
+ return error_reply("malformed", "ArgumentError", MALFORMED) unless req
678
904
  entry = @objects[obj_name]
679
- return error_reply("NameError", "no object #{obj_name} exposed by #{@node}/#{@app}") unless entry
905
+ return error_reply("no_object", "NameError", "no object #{obj_name} exposed by #{@node}/#{@app}") unless entry
680
906
  name = req[0]
681
907
  args = req[1]
682
908
  kw = req[2]
683
909
  arity = entry[1][name]
684
910
  if arity.nil?
685
- return error_reply("NoMethodError", "undefined method '#{name}' for #{@node}/#{@app}/#{obj_name} (not exposed)")
911
+ return error_reply("not_exposed", "NoMethodError",
912
+ "undefined method '#{name}' for #{@node}/#{@app}/#{obj_name} (not exposed)")
686
913
  end
687
914
  if arity >= 0 && args.size != arity
688
- return error_reply("ArgumentError", "wrong number of arguments (given #{args.size}, expected #{arity})")
915
+ return error_reply("arguments", "ArgumentError", "wrong number of arguments (given #{args.size}, expected #{arity})")
916
+ end
917
+ ckw = entry[3]
918
+ if ckw
919
+ if kw.key?(ckw)
920
+ return error_reply("arguments", "ArgumentError",
921
+ "#{ckw}: is given by Asterism, not by the caller " \
922
+ "(#{@node}/#{@app}/#{obj_name} is exposed with caller: :#{ckw})")
923
+ end
924
+ kw[ckw] = from
689
925
  end
926
+ stack = call_stack
927
+ stack << CurrentCall.new(from, verified, obj_name, name)
690
928
  begin
691
929
  value = invoke(entry[0], name, args, kw)
692
- Codec.pack(["ok", value])
693
- rescue EncodeError => e
694
- error_reply("Asterism::EncodeError", "the return value: #{e.message}")
695
930
  rescue => e
696
- error_reply(e.class.to_s, e.message)
931
+ return error_reply(e.equal?(@nesting_error) ? "nesting" : "raised", e.class.to_s, e.message)
932
+ ensure
933
+ stack.pop
934
+ end
935
+ begin
936
+ Codec.pack({ "value" => value })
937
+ rescue EncodeError => e
938
+ error_reply("unsendable", "Asterism::EncodeError", "the return value: #{e.message}")
939
+ end
940
+ end
941
+
942
+ # [method, args, kwargs] of a decoded request, or nil when it is not one:
943
+ # a map with "method" (a non-empty str), "args" (an array, absent: none)
944
+ # and "kwargs" (a map with str keys, absent: none). Other keys are
945
+ # ignored (room for later additions).
946
+ # @api private
947
+ def self.parse_request(req)
948
+ return nil unless req.is_a?(Hash)
949
+ m = req["method"]
950
+ return nil unless m.is_a?(String) && !m.empty?
951
+ args = req.key?("args") ? req["args"] : []
952
+ return nil unless args.is_a?(Array)
953
+ kw = req.key?("kwargs") ? req["kwargs"] : {}
954
+ return nil unless kw.is_a?(Hash)
955
+ keys = kw.keys
956
+ i = 0
957
+ while i < keys.size
958
+ return nil unless keys[i].is_a?(String)
959
+ i += 1
697
960
  end
961
+ [m, args, kw]
698
962
  end
699
963
 
700
964
  # @api private
@@ -703,13 +967,14 @@ module Asterism
703
967
  obj.public_send(name, *args)
704
968
  else
705
969
  opts = {}
706
- kw.each { |k, v| opts[k.to_s.to_sym] = v }
970
+ kw.each { |k, v| opts[k.to_sym] = v }
707
971
  obj.public_send(name, *args, **opts)
708
972
  end
709
973
  end
710
974
 
975
+ # {"error" => {"kind", "class", "message"}}, packed.
711
976
  # @api private
712
- def self.error_reply(klass, message)
713
- ::MessagePack.pack(["error", klass.to_s, message.to_s])
977
+ def self.error_reply(kind, klass, message)
978
+ ::MessagePack.pack({ "error" => { "kind" => kind, "class" => Codec.text(klass), "message" => Codec.text(message) } })
714
979
  end
715
980
  end