ruby_zmq_framework 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f85c8cb84cb6cfb2fc84e4c03d5fd0e3f00fbf0627a814cab9edbb4a3ca82434
4
+ data.tar.gz: 02dcec88c184e898f094a292ba3309f3a7a8dc3d477335b7314654681e681d59
5
+ SHA512:
6
+ metadata.gz: f3e2d7ece26853813dccd26d68d9924ab8028cc65d69610827690b9741c67f4910097c137523d3b3bd47cc31eb0cc5f4ef0b83ddf73ac736e2bfcb11585cb6f6
7
+ data.tar.gz: 1f4c6eb92e5d93ba39e07e2863f25fd1e1f6843aac60a317836bb517e7b731b8dd219b38e54f7529113dc2712366c67aa9e09d0eaea0e0aa95375ba7eac6d1df
data/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ### Changed
9
+ - The repo is now a reference implementation of a flow-based,
10
+ language-agnostic node runtime ("Node-RED without the UI"): the graph
11
+ lives in `flow.yml`, `bin/flowctl` computes wiring and runs it, nodes
12
+ are blackbox processes configured entirely from the environment, and
13
+ `PROTOCOL.md` is the one-page contract for joining from any language.
14
+ `examples/` (hand-wired scripts) became `nodes/` (env-wired processes).
15
+
16
+ ### Added (flow runtime)
17
+ - `RubyZmqFramework.boot`: builds a node from `BUS_PORT`, `BUS_PEERS`,
18
+ `BUS_SUBSCRIBES`, and `NODE_NAME`; standalone-friendly when unset.
19
+ - `RubyZmqFramework::Flow`: parses the manifest and computes each node's
20
+ peer wiring from topic publishers/subscribers.
21
+ - `bin/flowctl` (with `--plan`): port assignment, spawning, prefixed
22
+ output streaming, Ctrl-C teardown.
23
+ - `PROTOCOL.md`: transport, wire format, env contract, heartbeat schema,
24
+ and a minimal Python node.
25
+
26
+ ### Fixed
27
+ - The `ZeroMQBus` listener thread no longer dies silently on malformed
28
+ JSON, non-two-frame messages, or exceptions raised inside a
29
+ subscriber's `handle_message` — one bad message previously left the
30
+ node permanently deaf while its heartbeat kept reporting "ok".
31
+ - Unknown topics arriving off the wire no longer grow the subscriber map
32
+ without bound, and topic strings from the network are no longer
33
+ interned into symbols unless someone subscribed to them.
34
+ - The `StrictContract` check now runs before construction, so a class
35
+ missing a required method can no longer leak a live heartbeat thread
36
+ from a failed `.new`. The contract also applies to subclasses.
37
+
38
+ ### Added
39
+ - Local dispatch: messages published on a bus now reach subscribers on
40
+ that same bus, with the same symbolized-key payload representation as
41
+ wire delivery.
42
+ - Clean shutdown: `ZeroMQBus#close`, `FrameworkModule#stop_heartbeat`,
43
+ and `CanBridge#close`.
44
+ - Configurable endpoints: `bind_host:` keyword, peers as `"host:port"`
45
+ or full ZeroMQ endpoints, and `my_port = 0` for an OS-assigned
46
+ ephemeral port (readable via `ZeroMQBus#port`).
47
+ - `FrameworkModule#node_name`: set `@node_name` to give each node a
48
+ distinct heartbeat identity.
49
+ - ZeroMQ return codes are checked; failures raise
50
+ `RubyZmqFramework::Error` instead of passing silently.
51
+
52
+ ## [0.1.0] - 2026-07-21
53
+
54
+ - Initial version: `ZeroMQBus`, `StrictContract`, `FrameworkModule`
55
+ auto-heartbeat, `StateRegistry`, `CanBridge`, examples, tests.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paul Daniel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # ruby_zmq_framework
2
+
3
+ **Think Node-RED without the UI: a flow-based, language-agnostic runtime for
4
+ blackbox nodes wired together by topics.**
5
+
6
+ - **Nodes** are independent OS processes. Each one does one job, lives in one
7
+ file, and knows nothing about any other node — not their ports, not their
8
+ names, not their language.
9
+ - **Wires** are pub/sub topics carrying JSON, over ZeroMQ.
10
+ - **The graph is data**: [`flow.yml`](flow.yml) is the only artifact that
11
+ knows the topology. `bin/flowctl` reads it, computes the wiring, and runs
12
+ everything.
13
+ - **The contract is one page**: [`PROTOCOL.md`](PROTOCOL.md) is everything a
14
+ node in any language needs to join.
15
+
16
+ The design goal is LLM-friendliness: each node is a task you can hand to an
17
+ agent with the protocol page and a one-line description ("write a Go node
18
+ that decodes DBC frames") — it never needs the rest of the repo in context.
19
+
20
+ ## Quick start
21
+
22
+ You need the ZeroMQ library (`libzmq3-dev` on Debian/Ubuntu, `brew install
23
+ zeromq` on macOS), then:
24
+
25
+ ```bash
26
+ bundle install
27
+ bundle exec bin/flowctl
28
+ ```
29
+
30
+ That runs the demo graph from `flow.yml`: a simulated ECU blasting RPM data,
31
+ a telemetry node that commands a throttle cut on over-rev, a web dashboard
32
+ on <http://localhost:4567>, a state registry caching heartbeats and
33
+ telemetry, and a dashboard consumer syncing the registry's snapshot. Output
34
+ is streamed with a `[node_name]` prefix; Ctrl-C stops everything.
35
+
36
+ `bundle exec bin/flowctl --plan` prints the computed wiring without running
37
+ anything.
38
+
39
+ ## Writing a node
40
+
41
+ A Ruby node is a class with one method, booted from the environment:
42
+
43
+ ```ruby
44
+ require_relative '../lib/ruby_zmq_framework'
45
+ $stdout.sync = true
46
+
47
+ class RpmSmoother
48
+ include RubyZmqFramework::FrameworkModule
49
+
50
+ def initialize(bus)
51
+ @bus = bus
52
+ @window = []
53
+ end
54
+
55
+ def handle_message(topic, payload)
56
+ @window = (@window << payload[:rpm]).last(5)
57
+ broadcast(:engine_data_smooth, { rpm: @window.sum / @window.size })
58
+ end
59
+ end
60
+
61
+ RubyZmqFramework.boot(RpmSmoother)
62
+ sleep
63
+ ```
64
+
65
+ Note what's absent: no ports, no peers, no subscribe calls. Wiring comes
66
+ from environment variables (`BUS_PORT`, `BUS_PEERS`, `BUS_SUBSCRIBES`,
67
+ `NODE_NAME` — see `PROTOCOL.md`), which `flowctl` computes from the node's
68
+ entry in the manifest:
69
+
70
+ ```yaml
71
+ rpm_smoother:
72
+ cmd: ruby nodes/rpm_smoother.rb
73
+ subscribes: [engine_data]
74
+ publishes: [engine_data_smooth]
75
+ ```
76
+
77
+ Run standalone (no environment needed — it binds an ephemeral port) to poke
78
+ at a node in isolation: `bundle exec ruby nodes/rpm_smoother.rb`.
79
+
80
+ Every node automatically heartbeats every 5 seconds. `StrictContract`
81
+ raises at `.new` if a node forgets `handle_message` — loudly and
82
+ immediately, which is exactly the feedback an iterating agent needs.
83
+
84
+ ## Nodes in other languages
85
+
86
+ The bus is just two-frame ZeroMQ pub/sub — `[topic, json]` — and the whole
87
+ contract fits on one page: [`PROTOCOL.md`](PROTOCOL.md), including a
88
+ complete minimal Python node. Follow it, add a `cmd` entry to `flow.yml`,
89
+ and the language never matters again. A Python companion library exists at
90
+ [python_zmq_framework](https://github.com/pgdaniel/python_zmq_framework),
91
+ with the same `boot()`-from-environment contract, `node_name`, and clean
92
+ shutdown as this gem — a Python node under its `nodes/` and a Ruby node
93
+ under this repo's `nodes/` are interchangeable entries in one `flow.yml`.
94
+
95
+ ## What's in the box
96
+
97
+ | piece | file | job |
98
+ |-------|------|-----|
99
+ | `ZeroMQBus` | `lib/ruby_zmq_framework/zeromq_bus.rb` | hardened transport: poison-message-proof listener, per-handler error isolation, local dispatch, clean `close` |
100
+ | `FrameworkModule` | `lib/ruby_zmq_framework/framework.rb` | node mixin: contract enforcement, auto-heartbeat, `broadcast`, `node_name`, `stop_heartbeat` |
101
+ | `Flow` | `lib/ruby_zmq_framework/flow.rb` | parses `flow.yml`, computes each node's env wiring |
102
+ | `flowctl` | `bin/flowctl` | assigns ports, spawns nodes, prefixes output, tears down |
103
+ | `StateRegistry` | `lib/ruby_zmq_framework/state_registry.rb` | passive cluster-state cache; replays snapshots on request |
104
+ | `CanBridge` | `lib/ruby_zmq_framework/can_bridge.rb` | real SocketCAN frames → `can_frame` topic (classic CAN, via raw ioctls, no extra gem) |
105
+ | demo nodes | `nodes/*.rb` | one blackbox process per file |
106
+
107
+ Delivery is fire-and-forget (latest-value-wins; slow consumers drop old
108
+ messages), handlers on one bus never run concurrently, and a bad message or
109
+ a raising handler can never kill a node's listener. See `CHANGELOG.md` for
110
+ the full hardening history.
111
+
112
+ > **Note:** ZeroMQ is reached through
113
+ > [`ffi-rzmq`](https://github.com/chuckremes/ffi-rzmq), which is stable but
114
+ > hasn't seen a release in years. The wire format is deliberately plain
115
+ > two-frame PUB/SUB, so swapping bindings — or the transport itself — stays
116
+ > a contained change behind the three-method bus interface
117
+ > (`publish`/`subscribe`/`close`).
118
+
119
+ ## CAN hardware
120
+
121
+ Uncomment the `can_bridge` node in `flow.yml` (set `CAN_IFACE`, e.g.
122
+ `vcan0`) to relay real SocketCAN frames onto the bus as `can_frame`
123
+ messages. Needs an actual or virtual CAN interface; fails fast with the
124
+ underlying `Errno` if it doesn't exist.
125
+
126
+ ## Tests
127
+
128
+ ```bash
129
+ bundle exec rake test
130
+ ```
@@ -0,0 +1,92 @@
1
+ require 'socket'
2
+
3
+ module RubyZmqFramework
4
+ # Reads raw frames off a real SocketCAN interface (can0, vcan0, ...) and
5
+ # rebroadcasts each one onto the ZeroMQ bus. Pure producer: it has no
6
+ # interest in bus traffic, so handle_message is a no-op that only exists
7
+ # to satisfy FrameworkModule's contract.
8
+ #
9
+ # Talks to the kernel directly via Ruby's built-in Socket + a couple of
10
+ # raw ioctl/struct calls (see linux/can.h, linux/sockios.h) rather than
11
+ # a third-party gem, since no maintained "socketcan" gem is published on
12
+ # RubyGems.
13
+ #
14
+ # Classic CAN only: reads assume the 16-byte struct can_frame. That is
15
+ # safe even on an FD-enabled interface — the kernel only delivers
16
+ # 72-byte canfd_frames to sockets that opt in via CAN_RAW_FD_FRAMES,
17
+ # which this one never does — but it means FD traffic is invisible here.
18
+ class CanBridge
19
+ include FrameworkModule
20
+
21
+ CAN_RAW = 1
22
+ SIOCGIFINDEX = 0x8933
23
+ FRAME_SIZE = 16 # sizeof(struct can_frame): 4(id) + 1(len) + 3(pad) + 8(data)
24
+ CAN_EFF_FLAG = 0x80000000
25
+ CAN_EFF_MASK = 0x1FFFFFFF
26
+ CAN_SFF_MASK = 0x7FF
27
+
28
+ def self.parse_frame(raw)
29
+ id_raw, len = raw.unpack('L< C')
30
+ extended = (id_raw & CAN_EFF_FLAG) != 0
31
+ can_id = id_raw & (extended ? CAN_EFF_MASK : CAN_SFF_MASK)
32
+
33
+ { id: can_id, extended: extended, dlc: len, data: raw[8, len].bytes }
34
+ end
35
+
36
+ def initialize(bus, interface:, topic: :can_frame)
37
+ @bus = bus
38
+ @topic = topic
39
+ @socket = open_can_socket(interface)
40
+ @running = true
41
+ start_reader
42
+ end
43
+
44
+ def handle_message(topic, payload); end
45
+
46
+ # Stops the whole node: heartbeat, reader thread, CAN socket. Closing
47
+ # the socket from here is what interrupts the reader's blocking read.
48
+ def close
49
+ return unless @running
50
+
51
+ @running = false
52
+ stop_heartbeat
53
+ @socket.close unless @socket.closed?
54
+ @reader.join(2)
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ def open_can_socket(interface)
61
+ socket = Socket.new(Socket::PF_CAN, Socket::SOCK_RAW, CAN_RAW)
62
+ socket.bind(sockaddr_can(interface_index(socket, interface)))
63
+ socket
64
+ end
65
+
66
+ def interface_index(socket, interface)
67
+ ifreq = [interface].pack('a16') + ("\0" * 24)
68
+ socket.ioctl(SIOCGIFINDEX, ifreq)
69
+ ifreq[16, 4].unpack1('l')
70
+ end
71
+
72
+ def sockaddr_can(ifindex)
73
+ [Socket::AF_CAN, 0, ifindex].pack('S S l') + ("\0" * 8)
74
+ end
75
+
76
+ def start_reader
77
+ @reader = Thread.new do
78
+ while @running
79
+ begin
80
+ raw = @socket.read(FRAME_SIZE)
81
+ broadcast(@topic, self.class.parse_frame(raw)) if raw&.bytesize == FRAME_SIZE
82
+ rescue StandardError => e
83
+ break unless @running # close interrupted the blocking read
84
+
85
+ warn "[Framework Error] CanBridge read failed: #{e.message}"
86
+ sleep 1
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,85 @@
1
+ require 'yaml'
2
+
3
+ module RubyZmqFramework
4
+ # Parses a flow manifest (flow.yml) — the graph as data, Node-RED style.
5
+ # The manifest is the ONLY place that knows the topology; node processes
6
+ # learn their wiring from the environment variables computed here, and a
7
+ # node's code never mentions another node.
8
+ #
9
+ # nodes:
10
+ # ecu:
11
+ # cmd: ruby nodes/ecu.rb
12
+ # publishes: [engine_data]
13
+ # subscribes: [throttle_request]
14
+ # env: { SOME_KNOB: "42" } # optional, merged into the process env
15
+ class Flow
16
+ Node = Struct.new(:name, :cmd, :publishes, :subscribes, :env, keyword_init: true)
17
+
18
+ attr_reader :nodes
19
+
20
+ def self.load(path)
21
+ new(YAML.safe_load(File.read(path)))
22
+ end
23
+
24
+ def initialize(spec)
25
+ unless spec.is_a?(Hash) && spec['nodes'].is_a?(Hash)
26
+ raise Error, '[Framework Error] Flow manifest needs a top-level "nodes" map'
27
+ end
28
+
29
+ @nodes = spec['nodes'].map do |name, cfg|
30
+ cfg ||= {}
31
+ raise Error, "[Framework Error] Flow node #{name} needs a cmd" unless cfg['cmd']
32
+
33
+ Node.new(
34
+ name: name.to_s,
35
+ cmd: cfg['cmd'],
36
+ publishes: Array(cfg['publishes']).map(&:to_s),
37
+ subscribes: Array(cfg['subscribes']).map(&:to_s),
38
+ env: (cfg['env'] || {}).transform_values(&:to_s)
39
+ )
40
+ end
41
+ warn_about_deaf_subscriptions
42
+ end
43
+
44
+ # The environment for every node process, given a {name => port} map.
45
+ # This is the whole trick that keeps nodes blackboxes: each node's peer
46
+ # list is computed from who publishes the topics it subscribes to.
47
+ def wiring(ports)
48
+ @nodes.to_h do |node|
49
+ peers = peer_names(node).map { |name| "127.0.0.1:#{ports.fetch(name)}" }
50
+ [node.name, {
51
+ 'BUS_PORT' => ports.fetch(node.name).to_s,
52
+ 'BUS_PEERS' => peers.join(','),
53
+ 'BUS_SUBSCRIBES' => node.subscribes.join(','),
54
+ 'NODE_NAME' => node.name
55
+ }.merge(node.env)]
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ # Every FrameworkModule node broadcasts :heartbeat implicitly, so for
62
+ # that topic everyone counts as a publisher. A node never peers with
63
+ # itself — the bus already delivers its own publishes locally.
64
+ def peer_names(node)
65
+ node.subscribes
66
+ .flat_map { |topic| publisher_names(topic) }
67
+ .uniq - [node.name]
68
+ end
69
+
70
+ def publisher_names(topic)
71
+ return @nodes.map(&:name) if topic == 'heartbeat'
72
+
73
+ @nodes.select { |n| n.publishes.include?(topic) }.map(&:name)
74
+ end
75
+
76
+ def warn_about_deaf_subscriptions
77
+ published = @nodes.flat_map(&:publishes).uniq + ['heartbeat']
78
+ @nodes.each do |node|
79
+ (node.subscribes - published).each do |topic|
80
+ warn "[Framework Warning] #{node.name} subscribes to #{topic.inspect} but no node in the flow publishes it"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,109 @@
1
+ module RubyZmqFramework
2
+ module StrictContract
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ module ClassMethods
8
+ def requires_methods(*methods)
9
+ @required_methods = methods
10
+ end
11
+
12
+ def required_methods
13
+ inherited = superclass.respond_to?(:required_methods) ? superclass.required_methods : []
14
+ inherited | (@required_methods || [])
15
+ end
16
+
17
+ # The check runs BEFORE super (i.e. before allocate/initialize), so a
18
+ # violating class never gets partially constructed. This matters for
19
+ # FrameworkModule: its prepended Heartbeat starts a broadcasting thread
20
+ # inside initialize, and checking afterwards would leak that thread
21
+ # from a zombie instance whose .new appeared to fail.
22
+ def new(...)
23
+ missing = required_methods.reject do |m|
24
+ method_defined?(m) || private_method_defined?(m)
25
+ end
26
+ if missing.any?
27
+ raise NotImplementedError,
28
+ "[Framework Error] Contract Violation: #{self} missing #{missing.join(', ')}"
29
+ end
30
+ super
31
+ end
32
+ end
33
+ end
34
+
35
+ module FrameworkModule
36
+ HEARTBEAT_INTERVAL = 5 # seconds
37
+
38
+ def self.included(base)
39
+ base.include(StrictContract)
40
+ base.requires_methods(:handle_message)
41
+ base.prepend(Heartbeat)
42
+ end
43
+
44
+ # Prepended so it wraps whatever initialize the concrete module defines,
45
+ # starting the heartbeat only once @bus has actually been assigned.
46
+ module Heartbeat
47
+ def initialize(...)
48
+ super
49
+ start_heartbeat
50
+ end
51
+ end
52
+
53
+ def broadcast(topic, payload = {})
54
+ @bus.publish(topic, payload)
55
+ end
56
+
57
+ # Identity used in heartbeats. Precedence: @node_name set by the node
58
+ # itself, then NODE_NAME from the environment (how bin/flowctl names
59
+ # the processes it spawns), then the class name. Distinct names matter
60
+ # when several instances of one class run — otherwise they overwrite
61
+ # each other in any StateRegistry's active_nodes.
62
+ def node_name
63
+ @node_name || ENV['NODE_NAME'] || self.class.name || 'AnonymousNode'
64
+ end
65
+
66
+ # Gracefully stops the heartbeat thread. Call this before closing the
67
+ # bus the node broadcasts on. Wakes the thread out of its interval wait
68
+ # rather than killing it, so an in-flight broadcast always completes.
69
+ def stop_heartbeat
70
+ thread = @heartbeat_thread
71
+ return unless thread
72
+
73
+ @heartbeat_mutex.synchronize do
74
+ @heartbeat_running = false
75
+ @heartbeat_wakeup.signal
76
+ end
77
+ thread.join(HEARTBEAT_INTERVAL + 1)
78
+ @heartbeat_thread = nil
79
+ end
80
+
81
+ private
82
+
83
+ def start_heartbeat
84
+ @heartbeat_mutex = Mutex.new
85
+ @heartbeat_wakeup = ConditionVariable.new
86
+ @heartbeat_running = true
87
+
88
+ @heartbeat_thread = Thread.new do
89
+ loop do
90
+ begin
91
+ broadcast(:heartbeat, {
92
+ node_name: node_name,
93
+ status: "ok",
94
+ timestamp: Time.now.to_i
95
+ })
96
+ rescue StandardError => e
97
+ warn "[Framework Error] Heartbeat failed for #{node_name}: #{e.message}"
98
+ end
99
+
100
+ keep_going = @heartbeat_mutex.synchronize do
101
+ @heartbeat_wakeup.wait(@heartbeat_mutex, HEARTBEAT_INTERVAL) if @heartbeat_running
102
+ @heartbeat_running
103
+ end
104
+ break unless keep_going
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,31 @@
1
+ module RubyZmqFramework
2
+ # A passive, in-memory cache of cluster-wide state. It listens to
3
+ # heartbeats and telemetry broadcast by other FrameworkModule nodes and,
4
+ # on request, replays its current snapshot back onto the bus. It never
5
+ # makes a blocking call and never crashes when a peer goes quiet — a
6
+ # silent node simply stops getting its active_nodes timestamp updated.
7
+ class StateRegistry
8
+ include FrameworkModule
9
+
10
+ attr_reader :store
11
+
12
+ def initialize(bus)
13
+ @bus = bus
14
+ @store = { active_nodes: {}, telemetry: {} }
15
+ end
16
+
17
+ def handle_message(topic, payload)
18
+ case topic
19
+ when :heartbeat
20
+ @store[:active_nodes][payload[:node_name]] = {
21
+ status: payload[:status],
22
+ timestamp: payload[:timestamp]
23
+ }
24
+ when :request_global_state
25
+ broadcast(:global_state_snapshot, @store)
26
+ else
27
+ @store[:telemetry][topic] = payload
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,3 @@
1
+ module RubyZmqFramework
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,167 @@
1
+ require 'ffi-rzmq'
2
+ require 'json'
3
+ require 'monitor'
4
+
5
+ module RubyZmqFramework
6
+ class ZeroMQBus
7
+ # How long the listener blocks in poll before looping, in milliseconds.
8
+ POLL_TIMEOUT_MS = 100
9
+
10
+ # The port actually bound — differs from my_port when my_port is 0.
11
+ attr_reader :port
12
+
13
+ # my_port may be 0 to bind an OS-assigned ephemeral port (read it back
14
+ # via #port). peer_ports entries may be Integers (loopback ports),
15
+ # "host:port" strings, or full ZeroMQ endpoints ("tcp://10.0.0.5:5555").
16
+ # Pass bind_host: "0.0.0.0" to accept peers from other machines.
17
+ def initialize(my_port, peer_ports = [], bind_host: '127.0.0.1')
18
+ @context = ZMQ::Context.new
19
+
20
+ # Publisher Socket
21
+ @pub = @context.socket(ZMQ::PUB)
22
+ bind_endpoint = "tcp://#{bind_host}:#{my_port.to_i.zero? ? '*' : my_port}"
23
+ check! @pub.bind(bind_endpoint), "bind to #{bind_endpoint}"
24
+ @port = bound_port
25
+
26
+ # Subscriber Socket
27
+ @sub = @context.socket(ZMQ::SUB)
28
+ peer_ports.each do |peer|
29
+ endpoint = peer_endpoint(peer)
30
+ check! @sub.connect(endpoint), "connect to #{endpoint}"
31
+ end
32
+ check! @sub.setsockopt(ZMQ::SUBSCRIBE, ''), 'subscribe'
33
+
34
+ # Keyed by topic *string*, with no default proc: the listener looks up
35
+ # every topic that arrives off the wire, and a defaulting hash would
36
+ # insert a permanent empty entry per unknown topic — an unbounded,
37
+ # network-driven leak. A Monitor guards it because subscribe runs on
38
+ # the caller's thread while dispatch runs on the listener thread.
39
+ @local_subscribers = {}
40
+ @subscribers_lock = Monitor.new
41
+ @running = true
42
+ start_listener
43
+ end
44
+
45
+ # Stops the listener thread and releases both sockets and the context.
46
+ # Stop anything still publishing on this bus first (heartbeats, reader
47
+ # threads): publishing on a closed bus raises RubyZmqFramework::Error.
48
+ # Idempotent.
49
+ def close
50
+ return if @closed
51
+
52
+ @closed = true
53
+ @running = false
54
+ @listener.join(POLL_TIMEOUT_MS / 1000.0 + 1) unless Thread.current == @listener
55
+
56
+ [@sub, @pub].each do |sock|
57
+ sock.setsockopt(ZMQ::LINGER, 0)
58
+ sock.close
59
+ end
60
+ @context.terminate
61
+ nil
62
+ end
63
+
64
+ def subscribe(topic, module_instance)
65
+ @subscribers_lock.synchronize do
66
+ (@local_subscribers[topic.to_s] ||= []) << module_instance
67
+ end
68
+ end
69
+
70
+ def publish(topic, payload = {})
71
+ json = payload.to_json
72
+ check! @pub.send_strings([topic.to_s, json]), "publish #{topic}"
73
+
74
+ # A SUB socket never connects back to its own PUB, so without this,
75
+ # two modules sharing one bus in the same process could not hear each
76
+ # other. Delivering the serialized form keeps the payload
77
+ # representation identical whether a message arrived locally or over
78
+ # the wire (string keys become symbols either way).
79
+ dispatch(topic.to_s, json)
80
+ end
81
+
82
+ private
83
+
84
+ def check!(rc, action)
85
+ return if ZMQ::Util.resultcode_ok?(rc)
86
+
87
+ raise Error, "[Framework Error] ZeroMQ #{action} failed: #{ZMQ::Util.error_string}"
88
+ end
89
+
90
+ def bound_port
91
+ endpoint = []
92
+ check! @pub.getsockopt(ZMQ::LAST_ENDPOINT, endpoint), 'read bound endpoint'
93
+ # ffi-rzmq returns the endpoint with a trailing NUL, e.g. "tcp://127.0.0.1:5555\0"
94
+ endpoint.first.delete("\0").rpartition(':').last.to_i
95
+ end
96
+
97
+ def peer_endpoint(peer)
98
+ case peer
99
+ when Integer then "tcp://127.0.0.1:#{peer}"
100
+ when %r{\A[a-z]+://} then peer # already a full ZeroMQ endpoint
101
+ else "tcp://#{peer}" # "host:port"
102
+ end
103
+ end
104
+
105
+ def start_listener
106
+ @listener = Thread.new do
107
+ poller = ZMQ::Poller.new
108
+ poller.register_readable(@sub)
109
+
110
+ while @running
111
+ ready = poller.poll(POLL_TIMEOUT_MS)
112
+ if ready == -1
113
+ break unless @running
114
+
115
+ warn "[Framework Error] Listener poll failed: #{ZMQ::Util.error_string}"
116
+ sleep 0.1
117
+ next
118
+ end
119
+ next if ready.zero?
120
+
121
+ messages = []
122
+ unless ZMQ::Util.resultcode_ok?(@sub.recv_strings(messages))
123
+ warn "[Framework Error] Listener receive failed: #{ZMQ::Util.error_string}"
124
+ next
125
+ end
126
+
127
+ topic, json = messages
128
+ # Drop anything that doesn't match the two-frame [topic, json] wire format.
129
+ next if topic.nil? || json.nil?
130
+
131
+ dispatch(topic, json)
132
+ end
133
+ end
134
+ end
135
+
136
+ # A bad payload or a raising subscriber must never kill the listener
137
+ # thread: one poisoned message would otherwise leave the node deaf for
138
+ # good while its heartbeat keeps reporting "ok".
139
+ # Runs entirely inside the Monitor, which is reentrant: dispatch happens
140
+ # on the listener thread AND on whichever thread called publish, and
141
+ # serializing them guarantees a subscriber's handle_message is never
142
+ # executed concurrently — while a handler that publishes from within
143
+ # handle_message simply re-enters the lock it already holds.
144
+ def dispatch(topic_str, json)
145
+ @subscribers_lock.synchronize do
146
+ subscribers = @local_subscribers[topic_str]
147
+ return if subscribers.nil? || subscribers.empty?
148
+
149
+ begin
150
+ payload = JSON.parse(json, symbolize_names: true)
151
+ rescue JSON::ParserError => e
152
+ warn "[Framework Error] Dropping malformed payload on #{topic_str}: #{e.message}"
153
+ return
154
+ end
155
+
156
+ topic = topic_str.to_sym
157
+ # Dup so a handler that subscribes mid-dispatch can't mutate the
158
+ # list we're iterating.
159
+ subscribers.dup.each do |mod|
160
+ mod.handle_message(topic, payload)
161
+ rescue StandardError => e
162
+ warn "[Framework Error] #{mod.class} failed handling #{topic}: #{e.message}"
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,41 @@
1
+ require_relative "ruby_zmq_framework/version"
2
+ require_relative "ruby_zmq_framework/framework"
3
+ require_relative "ruby_zmq_framework/zeromq_bus"
4
+ require_relative "ruby_zmq_framework/state_registry"
5
+ require_relative "ruby_zmq_framework/can_bridge"
6
+ require_relative "ruby_zmq_framework/flow"
7
+
8
+ module RubyZmqFramework
9
+ class Error < StandardError; end
10
+
11
+ # Boots a node the flow-runtime way: all bus wiring comes from
12
+ # environment variables (set by bin/flowctl, or by hand), so node code
13
+ # never contains ports, peer lists, or subscription calls — a node is
14
+ # just a class with handle_message plus whatever it broadcasts.
15
+ #
16
+ # BUS_PORT port to bind (default 0 = OS-assigned ephemeral)
17
+ # BUS_PEERS comma-separated peer endpoints ("127.0.0.1:5555,...")
18
+ # BUS_SUBSCRIBES comma-separated topics routed to node#handle_message
19
+ # NODE_NAME heartbeat identity (defaults to the class name)
20
+ #
21
+ # node_class must take the bus as its first constructor argument. With
22
+ # no environment set, the node still boots standalone on an ephemeral
23
+ # port — handy for poking at a single node in isolation.
24
+ def self.boot(node_class, *args, **kwargs)
25
+ bus = ZeroMQBus.new(ENV.fetch('BUS_PORT', '0').to_i, env_list('BUS_PEERS'))
26
+ node = node_class.new(bus, *args, **kwargs)
27
+ env_list('BUS_SUBSCRIBES').each { |topic| bus.subscribe(topic, node) }
28
+
29
+ # Booted nodes are processes managed by a supervisor (bin/flowctl) or a
30
+ # terminal: exit quietly on TERM/INT instead of dumping a backtrace
31
+ # from an interrupted sleep. Frameworks that install their own traps
32
+ # afterwards (e.g. Sinatra) simply override these.
33
+ %w[TERM INT].each { |sig| trap(sig) { exit } }
34
+ node
35
+ end
36
+
37
+ def self.env_list(key)
38
+ ENV.fetch(key, '').split(',').map(&:strip).reject(&:empty?)
39
+ end
40
+ private_class_method :env_list
41
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_zmq_framework
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Paul Daniel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ffi-rzmq
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.20'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.20'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ description: 'Blackbox node processes wired together by pub/sub topics: the graph
56
+ lives in a flow.yml manifest, nodes are configured entirely from the environment,
57
+ and a one-page protocol lets any language join the bus. Includes a hardened ZeroMQ
58
+ transport with strict runtime module contracts.'
59
+ email:
60
+ - paulgdan@gmail.com
61
+ executables: []
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - CHANGELOG.md
66
+ - LICENSE.txt
67
+ - README.md
68
+ - lib/ruby_zmq_framework.rb
69
+ - lib/ruby_zmq_framework/can_bridge.rb
70
+ - lib/ruby_zmq_framework/flow.rb
71
+ - lib/ruby_zmq_framework/framework.rb
72
+ - lib/ruby_zmq_framework/state_registry.rb
73
+ - lib/ruby_zmq_framework/version.rb
74
+ - lib/ruby_zmq_framework/zeromq_bus.rb
75
+ homepage: https://github.com/pgdaniel/ruby_zmq_framework
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ source_code_uri: https://github.com/pgdaniel/ruby_zmq_framework
80
+ changelog_uri: https://github.com/pgdaniel/ruby_zmq_framework/blob/master/CHANGELOG.md
81
+ rubygems_mfa_required: 'true'
82
+ post_install_message:
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '2.7'
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.5.22
98
+ signing_key:
99
+ specification_version: 4
100
+ summary: Flow-based, language-agnostic node runtime over ZeroMQ — Node-RED without
101
+ the UI
102
+ test_files: []