asterism 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba14899dd37f9c4c21e148a2256d71545785da6f61a1f8f6701e2d0d189e9061
4
- data.tar.gz: 207e08ed177ffa93cd5c7173a84c31f519f59c8e076c6c641ccaa8db27712ea9
3
+ metadata.gz: 54a2e8d7f4b96b04dc56b290714d60a4a8d10cdc87d789ccf01bbb2b474f0459
4
+ data.tar.gz: be9f2f6c820e67ea513ea19a3d6f88437589cb2020615bc558b553f83e882673
5
5
  SHA512:
6
- metadata.gz: de0f85b8c36d4b3c991603004f151ea68952bd7fcc7fdbc76b992af80505b45be3612c5975e509e4f1415f553db8d91ea94940817c00768705bdbf1a011d45e6
7
- data.tar.gz: df7634a6c9453fb647c0f148199ff056c646451e3c839ac5cf02704db0ee7dfa7508d8aabdd9fde8005b8909dd6c7217f2d2b96a6e066ac6895d39d9a2af9481
6
+ metadata.gz: cb166817303dfd933dd1fd6a4444aecd966ce06b66347a4b058759c49dc0b0cd1397bbef60d05753c72ccf511a8f70ea5c49c8ffdfaa591eb2d2cf2255d8b1da
7
+ data.tar.gz: d0d0549e336a50af534813aa4ab74c068952bf3d43148cc7c2032c0f4447df0aae73c9d1d813f8aa99819310c13ae3e220d59376276195fed408506b34144399
data/CHANGELOG.md CHANGED
@@ -6,6 +6,78 @@ the earlier ones are only in this repository. From 0.4.0 the two Zenoh
6
6
  bindings (asterism-zenoh, picoruby-asterism-zenoh) carry the same minor
7
7
  version (a patch release of one may come without the other).
8
8
 
9
+ ## 0.7.0
10
+
11
+ **This release breaks compatibility: it speaks wire protocol 1 only.** A
12
+ node of 0.7.0 and a node of 0.6.0 or earlier (protocol 0) do not see each
13
+ other's objects, and a call between them fails: a protocol 0 call to a
14
+ 0.7.0 node is refused with the error kind `protocol`, a 0.7.0 call to an
15
+ older node is not answered, and their replies are malformed answers to
16
+ each other. Move every node of a network to 0.7.0 together (boards,
17
+ browsers, consoles, CRuby programs). The Ruby API stays as it was, with
18
+ the additions below. Needs asterism-zenoh / picoruby-asterism-zenoh 0.4.0
19
+ (unchanged), and on mruby a MessagePack that reads and writes bin (README,
20
+ "Using it").
21
+
22
+ Protocol 1 (`docs/protocol.md`, normative; `docs/protocol_review.md` says
23
+ what was decided and why):
24
+
25
+ - A call is a get on `asterism/<node>/<app>/<object>/call/<caller>`, where
26
+ `<caller>` is the calling node's ID. Its payload is a map
27
+ `{"method" => name, "args" => [...], "kwargs" => {...}}` (args and
28
+ kwargs left out when empty) instead of the array `[method, args,
29
+ kwargs]`.
30
+ - A reply is `{"value" => value}` or `{"error" => {"kind" => ..., "class"
31
+ => ..., "message" => ...}}` instead of `["ok", value]` /
32
+ `["error", class, message]`. The kinds: `raised`, `not_exposed`,
33
+ `no_object`, `arguments`, `malformed`, `unsendable`, `nesting`,
34
+ `protocol`.
35
+ - The meta is `{"methods" => [{"name" => name, "arity" => n}, ...]}`.
36
+ - Binary Strings go as MessagePack bin (CRuby: ASCII-8BIT Strings, no more
37
+ compatibility mode; mruby: `String#b`), and a bin arrives as a binary
38
+ String. The profile's icon is a bin.
39
+ - The profile has `"protocol" => 1`.
40
+ - Each application declares its own liveliness token
41
+ `asterism/<node>/<app>`; the node token `asterism/<node>`, which the
42
+ applications of a node shared, is gone. A node is present while any of
43
+ its applications or objects is.
44
+ - A payload is exactly one value: bytes after it, ext types and integers
45
+ outside the signed 64-bit range make it malformed. CRuby raises
46
+ `EncodeError` before sending an Integer outside int64 (it raised
47
+ `RangeError` before).
48
+ - A protocol 0 call (on the five-chunk `.../call` key) is answered with the
49
+ error kind `protocol`.
50
+
51
+ Added
52
+
53
+ - `Asterism.current_call` while an exposed method runs for a call (per
54
+ thread on CRuby, `net.current_call` too): `from` (the calling node),
55
+ `verified` (`:claimed`; `:local` for a call from the same application),
56
+ `object`, `method`. `Asterism::CurrentCall`.
57
+ - `Asterism.expose(name, obj, methods:, caller: :from)` (and
58
+ `net.expose`): passes the calling node's ID as the keyword `from:` and
59
+ refuses a call that brings `from:` itself.
60
+ - `RemoteError#remote_kind`: the error's kind, as above (nil when the
61
+ answer did not say).
62
+ - `proxy.asterism_call(name, *args, **kw)` and `proxy.async.asterism_call`:
63
+ a call by the method's name, for names a proxy cannot forward (`display`,
64
+ `hash`, `class`, `fail`, ...) and names that are not Ruby identifiers.
65
+ - `Asterism::PROTOCOL` (1).
66
+ - The conformance tests (`test/protocol/`) are protocol 1's: 55 values, 54
67
+ incoming queries, 24 outgoing steps; CRuby and mruby pass them all.
68
+
69
+ Changed
70
+
71
+ - `proxy.asterism_meta` returns the new meta form.
72
+ - `Asterism.connect`'s duplicate check also finds a copy of the
73
+ application that exposes nothing (it has its application token now).
74
+ - `host_test/msgpack.rb` (the mruby test's MessagePack) writes a binary
75
+ String as bin and reads a bin as one; the mruby test build adds
76
+ mruby-encoding (to tell a binary String).
77
+ - The documentation of the wire protocol (docs/protocol.md,
78
+ docs/protocol_review.md) and the conformance tests (X0, unreleased in
79
+ 0.6.0) are rewritten for protocol 1.
80
+
9
81
  ## 0.6.0
10
82
 
11
83
  One connection for everything and a lighter `Asterism.poll`; every 0.5.x
data/README.md CHANGED
@@ -14,6 +14,7 @@ repository holds the pure Ruby layers, written once for every Ruby:
14
14
  | `data/msgs/` | ROS 2 message types generated from ROS 2 Jazzy's definitions (Apache-2.0; tf2_msgs BSD-3-Clause; see NOTICE) |
15
15
  | `tools/` | The type generator `asterism_msggen.rb`, the Jazzy definitions it reads, `ros2_types.rb` (refreshes them from a ROS 2 image) |
16
16
  | `test/` | `test/msgs` (types, CDR; CRuby only), `test_asterism.rb` (objects and ROS between CRuby sessions), `test_profile.rb` (node profiles), `test_session.rb` (`Asterism.session`, `on_join` / `on_leave`), `test_api_*.rb` (the CRuby API), `bench_poll.rb` (`rake bench:poll`) |
17
+ | `test/protocol/` | the conformance samples of the wire protocol (`samples.json`), the driver that checks an implementation against them, and the Ruby node it checks (`rake test:protocol`, `rake test:protocol_mruby`; [docs/protocol.md](docs/protocol.md)) |
17
18
  | `host_test/` | `rake test:mruby`: the shared layer on mruby (profiles between two mruby processes; `Asterism.session`, `on_join` / `on_leave` and the time of a poll against `peer.rb`) |
18
19
  | `examples/` | CRuby: `node.rb` (objects with a board), `node_polled.rb` (the same with the portable API of the boards), `ros2_talker.rb` (topics, a timer, services with ROS 2), `ros2_rover.rb` (drive a ROS 2 robot: `/cmd_vel`, `/odom`, `/imu`), `zenoh.rb` (plain Zenoh), `mujoco/` (MuJoCo run from CRuby through Fiddle, its robot exposed as Asterism objects; see its README) |
19
20
 
@@ -42,7 +43,12 @@ profiles ([Profiles](#profiles): what a node runs on, where, a name and an
42
43
  icon); 0.6.0 adds `Asterism.session`
43
44
  ([One connection](#one-connection-asterismsession): the object layer's
44
45
  session for plain Zenoh and ROS 2 too) and makes `Asterism.poll` cheap when
45
- nothing changed.
46
+ nothing changed. **0.7.0 speaks wire protocol 1 only**
47
+ ([docs/protocol.md](docs/protocol.md)): it does not talk to the object
48
+ layer of earlier versions, so every node of a network moves to 0.7.0
49
+ together. It adds who called (`Asterism.current_call`, `expose(...,
50
+ caller: :from)`; [Who called](#who-called)), the kind of a remote error
51
+ (`RemoteError#remote_kind`) and `proxy.asterism_call`.
46
52
 
47
53
  ## Which API?
48
54
 
@@ -91,7 +97,7 @@ build but not yet tested. To install from the repositories instead:
91
97
 
92
98
  ```
93
99
  (cd ../asterism-zenoh && rake gem) && rake gem
94
- gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.0.gem pkg/asterism-0.6.0.gem
100
+ gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.0.gem pkg/asterism-0.7.0.gem
95
101
  ```
96
102
 
97
103
  From the working trees, without installing:
@@ -101,12 +107,14 @@ ruby -I lib -I ../asterism-zenoh/lib your_script.rb # after `rake compile` in
101
107
  ```
102
108
 
103
109
  `Asterism::ROS::TYPE_PATH` is `data/msgs` there; add your own directories.
104
- MessagePack sends every String as str (compatibility mode), as the boards
105
- do: their MessagePack does not read the bin type.
110
+ MessagePack sends a binary (ASCII-8BIT) String as bin and any other String
111
+ as str (protocol 1; [Values](#values)).
106
112
 
107
113
  mruby / PicoRuby: add this repository and picoruby-asterism-zenoh as gems
108
114
  (`conf.gem "path/to/asterism"`; a `MessagePack` module with `pack` /
109
- `unpack` is needed too), and put `data/msgs` on the device's file system at
115
+ `unpack` is needed too: for protocol 1 it must write a binary String
116
+ (`String#b`) as bin and read a bin as one, and refuse ext types, integers
117
+ outside int64 and bytes after the value), and put `data/msgs` on the device's file system at
110
118
  `/usr/share/asterism/msgs` (or add the place to
111
119
  `Asterism::ROS::TYPE_PATH`). Family mruby does this from pinned commits of
112
120
  these repositories.
@@ -120,6 +128,8 @@ rake test:objects # objects and Asterism::ROS between CRuby sessions over a
120
128
  rake test:api # the CRuby API (blocks, receiving threads, Enumerators)
121
129
  ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 rake test:objects test:api # the same through a zenohd router
122
130
  rake test:mruby # the shared layer on mruby (builds mruby; not part of `rake`)
131
+ rake test:protocol # the wire protocol's conformance samples and driver against CRuby (docs/protocol.md)
132
+ rake test:protocol_mruby # the same driver against mruby (builds mruby; not part of `rake`)
123
133
  rake bench:poll # the time and allocations of one Asterism.poll by nodes and objects (prints a table)
124
134
  ```
125
135
 
@@ -251,7 +261,8 @@ end
251
261
  |---|---|
252
262
  | `Asterism.connect(...) { \|net\| }` | yields an `Asterism::Net`, `Asterism.close` when the block ends; returns the block's value. Without a block, as before |
253
263
  | `Asterism.net` | the `Net` of the connection made without a block (nil when not connected) |
254
- | `net.expose` / `unexpose` / `exposed` | as `Asterism.expose`. The methods run on the receiving thread, in `run`, or in whatever polls |
264
+ | `net.expose` / `unexpose` / `exposed` | as `Asterism.expose` (also `caller: :from`). The methods run on the receiving thread, in `run`, or in whatever polls |
265
+ | `net.current_call` | `Asterism.current_call`: the call being answered on this thread (the receiving thread while it answers), or nil |
255
266
  | `net[path, timeout: 2.0]` | a proxy (`timeout` in seconds, or `timeout_ms:`) |
256
267
  | `net.each(pattern = "**")` | the proxies alive now; an Enumerator without a block. With a block it returns their number (1.0: `self`, as Ruby's `each` does; `net.count` is the number) |
257
268
  | `net.on_join { \|node\| }` / `net.on_leave { \|node\| }` | `Asterism.on_join` / `on_leave` of the portable API (the same blocks, told from `Asterism.poll`), here on the receiving thread; what they raise goes to `on_error`. `net.off_join(proc)` / `off_leave(proc)` remove one |
@@ -417,17 +428,19 @@ Asterism.nodes # => ["linux", "fmruby-bbbbbb"]
417
428
  | Call | Returns | Raises / notes |
418
429
  |---|---|---|
419
430
  | `Asterism.connect(locator, node:, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil, check_timeout: 1.5)` | `Asterism` | `locator`, `mode:`, `listen:` (and `config:` / `connect_timeout:` when given, CRuby only: TLS and the rest of zenoh's configuration; the boards raise `ArgumentError`) go to `Asterism::Zenoh::Session.open` (client of a router by default; `mode: :peer` with or without `listen:` without one). `node:` is this machine's ID, `app:` the application's name (one key chunk each: no `/ * $ ? #`, not starting with `@`; else `ArgumentError`). `Disconnected` when it cannot connect; `Error` when objects of the same `<node>/<app>` are already alive (a second copy of the application), or when already connected. Waits up to `check_timeout:` seconds for that check (a router answers at once; give more over a slow relay). Returns the module itself: on a board the module is the connection (one per application). The default is plain text without authentication; see [Over TLS](#over-tls-a-router-on-the-internet). |
420
- | `Asterism.expose(name, obj, methods:)` | `"<node>/<app>/<name>"` | `methods:` is an Array of names, or a Hash `name => number of arguments` (checked before the call; `-1` or the Array form: any). `ArgumentError` when `obj` has no such public method. Exposing a name again replaces it. |
431
+ | `Asterism.expose(name, obj, methods:, caller: nil)` | `"<node>/<app>/<name>"` | `methods:` is an Array of names, or a Hash `name => number of arguments` (checked before the call; `-1` or the Array form: any). `ArgumentError` when `obj` has no such public method. Exposing a name again replaces it. `caller: :from` passes the calling node's ID to every method as the keyword `from:` and refuses a call that brings `from:` itself ([Who called](#who-called)). |
432
+ | `Asterism.current_call` | `Asterism::CurrentCall` or nil | While an exposed method runs for a call: `from` (the calling node's ID), `verified` (`:claimed`, or `:local` for a call from this application), `object`, `method`. nil outside a call ([Who called](#who-called)). |
421
433
  | `Asterism.unexpose(name)` | true / false | |
422
434
  | `Asterism.poll` | true / false | Call from the update loop: polls Zenoh, answers the calls that came in, follows who is alive and calls the `on_join` / `on_leave` blocks. `false` once the connection is closed or lost. Cheap when nothing came (0.6.0): each queue is looked at by its count, and the list of nodes is made again only after a liveliness change. |
423
- | `Asterism.on_join { \|node\| }` / `Asterism.on_leave { \|node\| }` | `Asterism` | Another node appeared (its node token or one of its objects) / is gone (no token and no object left). The nodes there already join on the first polls after connecting; when the connection is lost, every node known until then leaves (on the next `Asterism.poll`). The blocks run from `Asterism.poll` only, never from the polling inside a waiting call; what they raise comes out of `Asterism.poll`. Each call adds a block; `Asterism.off_join(proc)` / `off_leave(proc)` remove one; `Asterism.close` forgets them (a lost connection does not). |
435
+ | `Asterism.on_join { \|node\| }` / `Asterism.on_leave { \|node\| }` | `Asterism` | Another node appeared (one of its applications or objects) / is gone (no application and no object left). The nodes there already join on the first polls after connecting; when the connection is lost, every node known until then leaves (on the next `Asterism.poll`). The blocks run from `Asterism.poll` only, never from the polling inside a waiting call; what they raise comes out of `Asterism.poll`. Each call adds a block; `Asterism.off_join(proc)` / `off_leave(proc)` remove one; `Asterism.close` forgets them (a lost connection does not). |
424
436
  | `Asterism[path, timeout: 2.0]` | `Proxy` | `path` is `<node>/<app>/<object>` without wildcards (`ArgumentError`). `timeout:` (seconds) or `timeout_ms:` is the time limit of its calls. Nothing is sent until a method is called. |
425
- | `proxy.<method>(*args, **kw)` | the remote return value | Waits for the answer (the proxy's time limit). `RemoteError` when it raised there or could not be called (not exposed: `NoMethodError`; wrong number of arguments: `ArgumentError`; no such object: `NameError`), `TimeoutError` when no answer came in time (also at once when nobody answers that key), `Disconnected`, `EncodeError` (before anything is sent) for a value MessagePack cannot carry. A block cannot be sent (`ArgumentError`). |
437
+ | `proxy.<method>(*args, **kw)` | the remote return value | Waits for the answer (the proxy's time limit). `RemoteError` when it raised there or could not be called (`remote_kind` says which: `raised`, or not exposed `not_exposed` (`NoMethodError`), wrong number of arguments `arguments` (`ArgumentError`), no such object `no_object` (`NameError`); [Errors](#errors)), `TimeoutError` when no answer came in time (also at once when nobody answers that key), `Disconnected`, `EncodeError` (before anything is sent) for a value MessagePack cannot carry. A block cannot be sent (`ArgumentError`). |
438
+ | `proxy.asterism_call(name, *args, **kw)` | the remote return value | The same call by the method's name (a String or Symbol): for names a proxy cannot forward (those `Object` and `Kernel` have: `display`, `hash`, `class`, `fail`, ...) and names that are not Ruby identifiers. `proxy.async.asterism_call(...)` returns a `Future`. |
426
439
  | `proxy.async.<method>(...)` | `Future` | Sends and returns at once. `EncodeError` / `Disconnected` are raised here. |
427
440
  | `future.done?` | true / false | Never waits (`Asterism.poll` moves it on). True once the answer came, the time ran out or the connection closed. |
428
441
  | `future.value` | the return value | Waits (polling) if not done; raises like a waiting call. `future.took` (seconds) / `took_ms`: time to the answer. |
429
442
  | `proxy.respond_to?(name)` / `proxy.remote_methods` | true / false, `[Symbol]` | From the object's meta (fetched once, `proxy.asterism_refresh` forgets it). `respond_to?` is false when the object does not answer; `remote_methods` raises then. (`proxy.methods` returns the same with a deprecation warning; from 1.0 it is `Object#methods`.) |
430
- | `proxy.asterism_meta` / `proxy.asterism_path` | Hash, String | `{"methods" => [[name, arity], ...]}` |
443
+ | `proxy.asterism_meta` / `proxy.asterism_path` | Hash, String | `{"methods" => [{"name" => name, "arity" => n}, ...]}` |
431
444
  | `Asterism.each(pattern = "**") { \|proxy\| }` | count (Array without a block) | The exposed objects alive now (this application's own included) whose `<node>/<app>/<object>` matches; `*` is one chunk, `**` any number. 1.0 returns the proxies from the block form instead of their number (Ruby's `each` returns its receiver); write `Asterism.each(pattern).size` for the count. |
432
445
  | `Asterism.nodes` | `[String]` | Node IDs alive now, this one first. |
433
446
  | `Asterism.connected?` / `node_id` / `app` / `exposed` / `lost_reason` | | |
@@ -444,19 +457,20 @@ and an icon. A console draws it with that; anyone can ask.
444
457
  ```ruby
445
458
  Asterism.profile(name: "Rover", icon: File.binread("rover.png")) # this node's (any time)
446
459
  Asterism.profile
447
- # => {"kind" => "cruby", "name" => "Rover", "icon" => "\x89PNG...", "icon_hash" => "2a8c0626"}
460
+ # => {"protocol" => 1, "kind" => "cruby", "name" => "Rover", "icon" => "\x89PNG...", "icon_hash" => "2a8c0626"}
448
461
  Asterism.profile_of("fmruby-aaaaaa")
449
- # => {"kind" => "family_mruby", "host" => "atoms3r", "icon" => "\x89PNG...", "icon_hash" => "..."}
462
+ # => {"protocol" => 1, "kind" => "family_mruby", "host" => "atoms3r", "icon" => "\x89PNG...", "icon_hash" => "..."}
450
463
  Asterism.profile_of("fmruby-aaaaaa", icon: false) # the same without the icon bytes
451
464
  Asterism.profile_of("an-older-node") # => nil
452
465
  ```
453
466
 
454
467
  | Field | Value | Default |
455
468
  |---|---|---|
469
+ | `protocol` | set by Asterism, never given: the wire protocol the node speaks (`Asterism::PROTOCOL`, 1). A node whose profile has none speaks protocol 0 (asterism before 0.7.0), which 0.7.0 does not talk to | |
456
470
  | `kind` | what Ruby runs the node: `family_mruby`, `picoruby`, `cruby`, `console` (a lower-case word: `a-z`, `0-9`, `_`, up to 32 bytes; other words are allowed) | `cruby` on CRuby, `picoruby` on mruby / PicoRuby; Family mruby's firmware sets `family_mruby` |
457
471
  | `host` | where it runs, e.g. `atoms3r`, `tab5`, `p4nano`, `browser`, `sim` (up to 32 bytes) | none; Family mruby's firmware sets it from the board |
458
472
  | `name` | a display name (up to 64 bytes) | none |
459
- | `icon` | the bytes of a PNG: at most 8 KB and 256x256 (32x32 is the size meant). PNG only: an SVG can carry scripts | the file at `Asterism.default_icon_path` when it is there: on CRuby the gem's `data/icon.png` (a ruby), on the boards `/usr/share/asterism/icon.png` |
473
+ | `icon` | the bytes of a PNG (a binary String; sent as a MessagePack bin): at most 8 KB and 256x256 (32x32 is the size meant). PNG only: an SVG can carry scripts | the file at `Asterism.default_icon_path` when it is there: on CRuby the gem's `data/icon.png` (a ruby), on the boards `/usr/share/asterism/icon.png` |
460
474
  | `icon_hash` | set by Asterism, never given: the icon's summary (8 hex digits: the Adler-32 of the PNG's chunk CRCs, so a board computes it from a few bytes) | |
461
475
 
462
476
  - **Setting.** `Asterism.profile(**fields)` changes the fields given and keeps
@@ -536,7 +550,7 @@ end
536
550
  another. Your own `put` does not come back to your own subscriber on the
537
551
  same session.
538
552
  - **Keys.** Keep your keys out of the object layer's: `asterism/<node>/<app>/**`
539
- is answered by its queryable (`get` there is a call), `asterism/<node>` and
553
+ is answered by its queryable (`get` there is a call), `asterism/<node>/<app>` and
540
554
  `asterism/<node>/<app>/<object>` are its liveliness tokens. Putting under
541
555
  `asterism/<node>/<app>/<object>/<anything>` (a state, a log) is fine:
542
556
  puts and subscriptions do not meet queryables or liveliness.
@@ -553,12 +567,18 @@ Asterism::Error (the Zenoh bindings define it)
553
567
  ├── Asterism::TimeoutError (no answer in time; old name Asterism::Timeout)
554
568
  │ └── Asterism::ROS::TimeoutError (old name Asterism::ROS::Timeout)
555
569
  ├── Asterism::EncodeError
556
- ├── Asterism::RemoteError (remote_class, remote_message)
570
+ ├── Asterism::RemoteError (remote_kind, remote_class, remote_message)
557
571
  ├── Asterism::ROS::UnknownType
558
572
  ├── Asterism::CDR::DecodeError
559
573
  └── Asterism::DeprecationError (only with Asterism.deprecations = :raise)
560
574
  ```
561
575
 
576
+ `RemoteError#remote_kind` says what happened on the other side, as a word
577
+ of the protocol (docs/protocol.md 4.4): `raised` (the method raised;
578
+ `remote_class` is its class there), `not_exposed`, `no_object`,
579
+ `arguments`, `malformed`, `unsendable`, `nesting`, `protocol` (the other
580
+ side speaks a different protocol); nil when the answer did not say.
581
+
562
582
  The old names `Asterism::Timeout` and `Asterism::ROS::Timeout` still work
563
583
  (they warn once). Inside `module Asterism`, `Timeout` is Ruby's `Timeout`
564
584
  module again. The ROS layer raises `Zenoh::ClosedError` when its session
@@ -658,22 +678,71 @@ session is closed, its entities raise `Zenoh::ClosedError`).
658
678
 
659
679
  ## Values
660
680
 
661
- Only what MessagePack carries: `nil`, `true`, `false`, `Integer` (64 bit),
662
- `Float`, `String`, `Array`, `Hash`. A `Symbol` is sent as a `String` (also
663
- as a Hash key: keyword arguments arrive as Symbols again). Anything else in
664
- the arguments raises `EncodeError` before sending; in a return value the
665
- caller gets `RemoteError` (`Asterism::EncodeError`). Nesting is limited to
666
- 16 levels.
681
+ Only what MessagePack carries: `nil`, `true`, `false`, `Integer` (signed
682
+ 64 bit), `Float`, `String`, `Array`, `Hash`. A `Symbol` is sent as a
683
+ `String` and arrives as one (also as a Hash key; keyword names become
684
+ Symbols again). A binary String (`String#b`: ASCII-8BIT on CRuby, the
685
+ binary flag on mruby) goes as a MessagePack bin and arrives as a binary
686
+ String; any other String goes as text (str). Anything else in the
687
+ arguments, or an Integer outside the signed 64-bit range, raises
688
+ `EncodeError` before sending; in a return value the caller gets
689
+ `RemoteError` (`unsendable`). Nesting is limited to 16 levels.
690
+
691
+ - Hash keys: use String (or Symbol) and Integer keys for anything another
692
+ language reads (Python and JavaScript refuse others by default).
693
+ - Times: send an Integer of nanoseconds since the epoch (or name the unit:
694
+ `timeout_ms`); there is no time type.
695
+ - A keyword given as `nil` and one left out differ: the callee's default
696
+ applies only to the one left out.
697
+ - A JavaScript caller's `1.0` arrives as the Integer `1`.
698
+
699
+ ## Who called
700
+
701
+ The caller's node ID travels in the call's key (`.../call/<caller>`). An
702
+ exposed method can read it:
703
+
704
+ ```ruby
705
+ class Rover
706
+ def stop
707
+ c = Asterism.current_call # nil outside a call
708
+ log("stop from #{c.from}") # c.verified: :claimed (:local from this app)
709
+ end
710
+
711
+ def drive(v, w, from:) # caller: :from fills from: in
712
+ log("drive from #{from}")
713
+ end
714
+ end
715
+ Asterism.expose("rover", Rover.new, methods: { stop: 0 })
716
+ Asterism.expose("wheels", Rover.new, methods: { drive: 2 }, caller: :from)
717
+ ```
718
+
719
+ - `Asterism.current_call` is set only while a method runs for a call (per
720
+ thread on CRuby). Calls answered while that method waits for its own
721
+ call come on top of it and go when they end.
722
+ - With `caller: :from`, a call that passes `from:` itself is refused
723
+ (`RemoteError`, `remote_kind` `arguments`): `from` is always the one
724
+ Asterism took from the call.
725
+ - **It is a claim.** Any client can call with any name. A router's access
726
+ control makes it binding: with mutual TLS, zenohd's ACL can let each
727
+ certificate query only keys ending in its own name
728
+ (`asterism/*/*/*/call/<name>`; docs/protocol.md 4.7). Without such a
729
+ router, use `from` to avoid mistakes, not to decide who may do what.
667
730
 
668
731
  ## Keys and encoding
669
732
 
733
+ Wire protocol 1. The full description, for implementations in other
734
+ languages, is [docs/protocol.md](docs/protocol.md) (with the conformance
735
+ tests); why it has this shape (the review of protocol 0's Ruby-shaped
736
+ parts, and what was decided) is
737
+ [docs/protocol_review.md](docs/protocol_review.md).
738
+
670
739
  | Key | Zenoh | Payload |
671
740
  |---|---|---|
672
- | `asterism/<node>/<app>/<object>/call` | get / queryable | query: `[method, [args...], {kwargs}]`; reply: `["ok", value]` or `["error", class name, message]` |
673
- | `asterism/<node>/<app>/<object>/meta` | get / queryable | reply: `{"methods" => [[name, arity], ...]}` (arity `-1` when not declared). The object may be `*` in the query: one reply per object |
674
- | `asterism/<node>` | liveliness | the node is connected |
741
+ | `asterism/<node>/<app>/<object>/call/<caller>` | get / queryable | query: `{"method" => name, "args" => [...], "kwargs" => {...}}` (args and kwargs left out when empty); reply: `{"value" => value}` or `{"error" => {"kind" => ..., "class" => ..., "message" => ...}}`. `<caller>` is the calling node's ID |
742
+ | `asterism/<node>/<app>/<object>/meta` | get / queryable | reply: `{"methods" => [{"name" => name, "arity" => n}, ...]}` (arity `-1` when not declared). The object may be `*` in the query: one reply per object |
743
+ | `asterism/<node>/<app>` | liveliness | the application is connected (a node is there while any of its applications or objects is) |
675
744
  | `asterism/<node>/<app>/<object>` | liveliness | the object is exposed |
676
- | `asterism/<node>/@profile` | get / queryable, and put | query (parameter `icon=0` for no icon): reply the node's profile, a Hash (`kind`, `host`, `name`, `icon`, `icon_hash`; [Profiles](#profiles)). The node also puts the profile without the icon at connect and on each change. `@profile` is a verbatim chunk, so `asterism/**` does not match it: the object layer's wildcards never see it. An application's own profile would go at `asterism/<node>/<app>/@profile` (not in 0.6.0) |
745
+ | `asterism/<node>/@profile` | get / queryable, and put | query (parameter `icon=0` for no icon): reply the node's profile, a Hash (`kind`, `host`, `name`, `icon`, `icon_hash`; [Profiles](#profiles)). The node also puts the profile without the icon at connect and on each change. `@profile` is a verbatim chunk, so `asterism/**` does not match it: the object layer's wildcards never see it. An application's own profile would go at `asterism/<node>/<app>/@profile` (not yet) |
677
746
 
678
747
  One queryable per application (`asterism/<node>/<app>/**`), and one for the
679
748
  profile (`asterism/<node>/@profile`). A call whose
@@ -728,9 +797,9 @@ nested type (`geometry_msgs/msg/Twist`) at start-up took 0.6 KB more.
728
797
 
729
798
  - Objects cannot be passed by reference (a return value is a copy), blocks
730
799
  cannot be sent, there are no events (later stages).
731
- - The node token `asterism/<node>` is shared by every application of that
732
- node; when one of them closes, the others still list the node through
733
- their objects, but a watcher may see the node token go away.
800
+ - The wire protocol is protocol 1 only: nodes of asterism before 0.7.0
801
+ (protocol 0) do not see 0.7.0's objects and a call between the two
802
+ fails (a protocol 0 call is refused with `remote_kind` `protocol`).
734
803
  - No authentication by default (plain TCP): a trusted LAN is assumed, or
735
804
  TLS through a router (CRuby; [Over TLS](#over-tls-a-router-on-the-internet)).
736
805
  - One object-layer connection per process (per VM on the boards).
@@ -153,12 +153,16 @@ module Asterism
153
153
  def exposed = ::Asterism.exposed
154
154
 
155
155
  # Asterism.expose: methods: an Array of names or a Hash name => number
156
- # of arguments. The object's methods run on the receiving thread (or in
156
+ # of arguments; caller: :from passes the calling node's ID as that
157
+ # keyword. The object's methods run on the receiving thread (or in
157
158
  # run, or in whatever polls).
158
- def expose(name, obj, methods:)
159
- ::Asterism.expose(name, obj, methods: methods)
159
+ def expose(name, obj, methods:, caller: nil)
160
+ ::Asterism.expose(name, obj, methods: methods, caller: caller)
160
161
  end
161
162
 
163
+ # Asterism.current_call: the call being answered on this thread, or nil.
164
+ def current_call = ::Asterism.current_call
165
+
162
166
  def unexpose(name)
163
167
  ::Asterism.unexpose(name)
164
168
  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.0"
6
7
  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
@@ -245,8 +293,8 @@ module Asterism
245
293
  self
246
294
  end
247
295
 
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
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
250
298
  # most check_ms. @api private
251
299
  def self.already_there?(s, node, app, check_ms = 1500)
252
300
  get_ms = check_ms > 1000 ? check_ms - 500 : check_ms
@@ -301,7 +349,7 @@ module Asterism
301
349
  @session = nil
302
350
  @objects = {}
303
351
  @alive = {}
304
- @nodes = {}
352
+ @apps = {}
305
353
  @node_list = nil
306
354
  @nodes_changed = true
307
355
  @proxies = {}
@@ -313,7 +361,7 @@ module Asterism
313
361
  @releasing = nil
314
362
  end
315
363
  end
316
- @node_token = nil
364
+ @app_token = nil
317
365
  @queryable = nil
318
366
  @watch = nil
319
367
  @profile_queryable = nil
@@ -376,7 +424,11 @@ module Asterism
376
424
  # Makes obj callable from other machines as <node>/<app>/<name>. Only the
377
425
  # listed methods can be called. methods: is an Array of names, or a Hash
378
426
  # name => number of arguments (checked before calling; -1 for any).
379
- 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)
380
432
  s = session!
381
433
  name = check_name("object", name)
382
434
  table = {}
@@ -389,9 +441,17 @@ module Asterism
389
441
  table.each_key do |m|
390
442
  raise ArgumentError, "#{obj.class} has no public method #{m}" unless obj.respond_to?(m)
391
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
392
452
  unexpose(name)
393
453
  token = s.liveliness("#{ROOT}/#{@node}/#{@app}/#{name}")
394
- @objects[name] = [obj, table, token]
454
+ @objects[name] = [obj, table, token, kw]
395
455
  "#{@node}/#{@app}/#{name}"
396
456
  rescue Asterism::Zenoh::Error => e
397
457
  raise Disconnected, e.message
@@ -424,7 +484,7 @@ module Asterism
424
484
 
425
485
  # ------------------------------------------------------------ nodes coming and going
426
486
 
427
- # 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
428
488
  # objects). The nodes there already join on the first polls after
429
489
  # connecting. on_leave { |node| }: a node is gone (no token and no object
430
490
  # left); when the connection is lost, every node known until then leaves.
@@ -546,7 +606,11 @@ module Asterism
546
606
  # Polls until the block is true. Answers incoming calls meanwhile.
547
607
  # @api private
548
608
  def self.wait_until
549
- 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
550
614
  @depth = (@depth || 0) + 1
551
615
  begin
552
616
  until yield
@@ -592,13 +656,13 @@ module Asterism
592
656
  i += 1
593
657
  rest = key[ROOT.length + 1, key.length].to_s
594
658
  parts = rest.split("/")
595
- if parts.size == 1
659
+ if parts.size == 2 # an application: asterism/<node>/<app>
596
660
  if alive
597
- @nodes[rest] = true
661
+ @apps[rest] = parts[0]
598
662
  else
599
- @nodes.delete(rest)
663
+ @apps.delete(rest)
600
664
  end
601
- elsif parts.size == 3
665
+ elsif parts.size == 3 # an object: asterism/<node>/<app>/<object>
602
666
  if alive
603
667
  @alive[rest] = parts[0]
604
668
  else
@@ -622,7 +686,7 @@ module Asterism
622
686
  return list if list
623
687
  list = [@node]
624
688
  seen = { @node => true }
625
- names = @nodes.keys
689
+ names = @apps.values
626
690
  i = 0
627
691
  while i < names.size
628
692
  n = names[i]
@@ -689,14 +753,16 @@ module Asterism
689
753
  # @api private
690
754
  def self.call_async(path, method, args, kwargs, timeout_ms)
691
755
  parts = split_path(path)
692
- payload = Codec.pack([method.to_s, args, kwargs])
756
+ payload = Codec.pack(request(method, args, kwargs))
693
757
  if parts[0] == @node && parts[1] == @app && @session
694
758
  # This application's own object: no network (a session does not see
695
759
  # its own queryable), same encoding and checks as a remote call.
696
- return Future.answered(path, method, dispatch(parts[2], payload))
760
+ return Future.answered(path, method, dispatch(parts[2], payload, @node, :local))
697
761
  end
698
762
  begin
699
- 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)
700
766
  rescue Asterism::Zenoh::Error => e
701
767
  lost(e.message)
702
768
  raise Disconnected, e.message
@@ -704,18 +770,28 @@ module Asterism
704
770
  Future.new(path, method, g, now_ms + timeout_ms, timeout_ms)
705
771
  end
706
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
+
707
783
  # @api private
708
784
  def self.call(path, method, args, kwargs, timeout_ms)
709
785
  call_async(path, method, args, kwargs, timeout_ms).value
710
786
  end
711
787
 
712
- # The meta reply of path: {"methods" => [[name, arity], ...]}.
788
+ # The meta reply of path: {"methods" => [{"name" => n, "arity" => a}, ...]}.
713
789
  # @api private
714
790
  def self.meta(path, timeout_ms = DEFAULT_TIMEOUT_MS)
715
791
  parts = split_path(path)
716
792
  if parts[0] == @node && parts[1] == @app && @session
717
793
  m = meta_of(parts[2])
718
- 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
719
795
  return m
720
796
  end
721
797
  begin
@@ -730,6 +806,21 @@ module Asterism
730
806
 
731
807
  # ------------------------------------------------------------ answering
732
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
+
733
824
  # @api private
734
825
  def self.answer_calls
735
826
  qa = @queryable
@@ -753,62 +844,121 @@ module Asterism
753
844
  end
754
845
  end
755
846
 
756
- # asterism/<node>/<app>/<object>/<call|meta>; the node and app may come
757
- # 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).
758
850
  # @api private
759
851
  def self.answer(q)
760
852
  parts = q.key.split("/")
761
- return unless parts.size == 5
853
+ n = parts.size
854
+ return unless n == 5 || n == 6
762
855
  obj = parts[3]
763
856
  kind = parts[4]
764
857
  prefix = "#{ROOT}/#{@node}/#{@app}/"
765
858
  if kind == "meta"
859
+ return unless n == 5
766
860
  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
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))
770
876
  end
771
- elsif kind == "call" && obj != "*"
772
- q.reply("#{prefix}#{obj}/call", dispatch(obj, q.payload))
773
877
  end
774
878
  end
775
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
+
776
887
  # @api private
777
888
  def self.meta_of(name)
778
889
  entry = @objects[name]
779
890
  return nil unless entry
780
891
  list = []
781
- entry[1].each { |m, n| list << [m, n] }
892
+ entry[1].each { |m, n| list << { "name" => m, "arity" => n } }
782
893
  { "methods" => list }
783
894
  end
784
895
 
785
- # 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).
786
900
  # @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
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
792
904
  entry = @objects[obj_name]
793
- 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
794
906
  name = req[0]
795
907
  args = req[1]
796
908
  kw = req[2]
797
909
  arity = entry[1][name]
798
910
  if arity.nil?
799
- 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)")
800
913
  end
801
914
  if arity >= 0 && args.size != arity
802
- 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
803
925
  end
926
+ stack = call_stack
927
+ stack << CurrentCall.new(from, verified, obj_name, name)
804
928
  begin
805
929
  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
930
  rescue => e
810
- 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
811
960
  end
961
+ [m, args, kw]
812
962
  end
813
963
 
814
964
  # @api private
@@ -817,13 +967,14 @@ module Asterism
817
967
  obj.public_send(name, *args)
818
968
  else
819
969
  opts = {}
820
- kw.each { |k, v| opts[k.to_s.to_sym] = v }
970
+ kw.each { |k, v| opts[k.to_sym] = v }
821
971
  obj.public_send(name, *args, **opts)
822
972
  end
823
973
  end
824
974
 
975
+ # {"error" => {"kind", "class", "message"}}, packed.
825
976
  # @api private
826
- def self.error_reply(klass, message)
827
- ::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) } })
828
979
  end
829
980
  end
data/mrblib/future.rb CHANGED
@@ -48,16 +48,22 @@ module Asterism
48
48
  @finished
49
49
  end
50
50
 
51
+ # The answer: {"value" => v} gives v, {"error" => {"kind", "class",
52
+ # "message"}} raises RemoteError; anything else is a malformed answer.
51
53
  def value
52
54
  raw = raw_value
53
- unless raw.is_a?(Array) && (raw[0] == "ok" || raw[0] == "error")
54
- raise ::Asterism::Error, "malformed answer from #{@path}"
55
+ raise ::Asterism::Error, "malformed answer from #{@path}" unless raw.is_a?(Hash)
56
+ if raw.key?("error")
57
+ e = raw["error"]
58
+ raise ::Asterism::Error, "malformed answer from #{@path}" unless e.is_a?(Hash)
59
+ kind = e["kind"]
60
+ raise ::Asterism::RemoteError.new(e["class"], e["message"], kind.is_a?(String) ? kind : nil)
55
61
  end
56
- raise ::Asterism::RemoteError.new(raw[1], raw[2]) if raw[0] == "error"
57
- raw[1]
62
+ raise ::Asterism::Error, "malformed answer from #{@path}" unless raw.key?("value")
63
+ raw["value"]
58
64
  end
59
65
 
60
- # The decoded answer (meta replies are not ["ok", value] pairs).
66
+ # The decoded answer (meta and profile replies are not value maps).
61
67
  def raw_value
62
68
  ::Asterism.wait_until { done? } unless done?
63
69
  if @reply.nil?
data/mrblib/profile.rb CHANGED
@@ -7,11 +7,14 @@
7
7
  #
8
8
  # Key: asterism/<node>/@profile (a queryable of every application connected
9
9
  # as that node). The reply is a MessagePack Hash:
10
+ # "protocol" the wire protocol the node speaks (Asterism::PROTOCOL, 1);
11
+ # a node without it speaks protocol 0
10
12
  # "kind" what Ruby runs the node: "family_mruby", "picoruby",
11
13
  # "cruby", "console" (others are allowed)
12
14
  # "host" where it runs, optional ("atoms3r", "tab5", "sim", ...)
13
15
  # "name" a display name, optional
14
- # "icon" PNG bytes, optional (8 KB and 256x256 at most)
16
+ # "icon" PNG bytes as a MessagePack bin, optional (8 KB and
17
+ # 256x256 at most)
15
18
  # "icon_hash" the icon's summary (Profile.png_info): a reader that has
16
19
  # that icon already asks again with the parameter icon=0 and
17
20
  # gets the rest without it
@@ -87,6 +90,13 @@ module Asterism
87
90
  nil
88
91
  end
89
92
 
93
+ # The bytes as a binary String (String#b), so that they are sent as a
94
+ # MessagePack bin; as they are where String#b is missing.
95
+ # @api private
96
+ def self.binary(s)
97
+ s.respond_to?(:b) ? s.b : s
98
+ end
99
+
90
100
  # @api private
91
101
  def self.type?(b, pos, c0, c1, c2, c3)
92
102
  b.getbyte(pos + 4) == c0 && b.getbyte(pos + 5) == c1 && b.getbyte(pos + 6) == c2 && b.getbyte(pos + 7) == c3
@@ -107,6 +117,8 @@ module Asterism
107
117
  kind = h["kind"]
108
118
  return nil unless kind.is_a?(String) && kind?(kind)
109
119
  out = { "kind" => kind }
120
+ pr = h["protocol"]
121
+ out["protocol"] = pr if pr.is_a?(Integer) && pr >= 0
110
122
  host = h["host"]
111
123
  out["host"] = host if host.is_a?(String) && !host.empty? && host.bytesize <= HOST_MAX
112
124
  name = h["name"]
@@ -301,7 +313,7 @@ module Asterism
301
313
  # @api private
302
314
  def self.profile_hash(with_icon)
303
315
  set = @profile_set || {}
304
- h = { "kind" => set["kind"] || default_profile_kind }
316
+ h = { "protocol" => PROTOCOL, "kind" => set["kind"] || default_profile_kind }
305
317
  h["host"] = set["host"] if set["host"]
306
318
  h["name"] = set["name"] if set["name"]
307
319
  icon = profile_icon
@@ -324,7 +336,8 @@ module Asterism
324
336
  bytes = path ? read_icon_file(path) : nil
325
337
  end
326
338
  info = bytes ? Profile.png_info(bytes) : nil
327
- @profile_icon = info ? [bytes, info[2]] : false
339
+ # A binary String: it goes as a MessagePack bin.
340
+ @profile_icon = info ? [Profile.binary(bytes), info[2]] : false
328
341
  end
329
342
  @profile_icon ? @profile_icon : nil
330
343
  end
data/mrblib/proxy.rb CHANGED
@@ -3,9 +3,9 @@ module Asterism
3
3
  # Any method it does not have itself becomes a call that waits for the
4
4
  # answer. respond_to? and remote_methods come from the object's meta.
5
5
  #
6
- # Its own methods (not forwarded): async, asterism_path, asterism_meta,
7
- # asterism_refresh, remote_methods, respond_to?, methods, inspect, to_s
8
- # and those of Object.
6
+ # Its own methods (not forwarded): async, asterism_call, asterism_path,
7
+ # asterism_meta, asterism_refresh, remote_methods, respond_to?, methods,
8
+ # inspect, to_s and those of Object.
9
9
  class Proxy
10
10
  # Conversions Ruby asks for implicitly (puts, Array#flatten, ...): never
11
11
  # forwarded.
@@ -27,6 +27,15 @@ module Asterism
27
27
  ::Asterism.call(@path, name, args, kw, @timeout_ms)
28
28
  end
29
29
 
30
+ # Calls the remote method by its name (a String or Symbol): for the
31
+ # names the proxy cannot forward (those Object and Kernel define:
32
+ # display, hash, class, fail, ...) and for names that are not Ruby
33
+ # identifiers (from objects written in other languages).
34
+ def asterism_call(name, *args, **kw, &blk)
35
+ raise ::ArgumentError, "a block cannot be sent to #{@path}" if blk
36
+ ::Asterism.call(@path, name.to_s, args, kw, @timeout_ms)
37
+ end
38
+
30
39
  # In Ruby rather than through respond_to_missing?: Object#respond_to? is
31
40
  # C, and calling back into Ruby from it (which then waits for the meta)
32
41
  # costs another interpreter entry on the C stack.
@@ -52,7 +61,8 @@ module Asterism
52
61
  remote_methods
53
62
  end
54
63
 
55
- # The meta reply, fetched once: {"methods" => [[name, arity], ...]}.
64
+ # The meta reply, fetched once:
65
+ # {"methods" => [{"name" => name, "arity" => n}, ...]}.
56
66
  def asterism_meta
57
67
  @meta ||= ::Asterism.meta(@path, @timeout_ms)
58
68
  end
@@ -67,7 +77,9 @@ module Asterism
67
77
  def remote_names
68
78
  list = asterism_meta["methods"]
69
79
  return [] unless list.is_a?(Array)
70
- list.map { |pair| pair.is_a?(Array) ? pair[0].to_s : pair.to_s }
80
+ names = []
81
+ list.each { |m| names << m["name"].to_s if m.is_a?(Hash) && m["name"].is_a?(String) }
82
+ names
71
83
  end
72
84
 
73
85
  # The same object, calls that do not wait: each returns a Future.
@@ -100,6 +112,12 @@ module Asterism
100
112
  !::Asterism::Proxy::LOCAL_ONLY.include?(name.to_sym)
101
113
  end
102
114
 
115
+ # Proxy#asterism_call that does not wait: returns a Future.
116
+ def asterism_call(name, *args, **kw, &blk)
117
+ raise ::ArgumentError, "a block cannot be sent to #{@path}" if blk
118
+ ::Asterism.call_async(@path, name.to_s, args, kw, @timeout_ms)
119
+ end
120
+
103
121
  def inspect
104
122
  "#<Asterism::AsyncProxy #{@path}>"
105
123
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asterism
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katsuhiko Kageyama