nnq-cli 0.2.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.
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ module CLI
5
+ # Runner for REQ sockets (synchronous request-reply client).
6
+ #
7
+ # nnq's REQ is cooked: #send_request takes the request body and
8
+ # blocks until the matching reply arrives, returning the reply body.
9
+ class ReqRunner < BaseRunner
10
+ private
11
+
12
+
13
+ def run_loop(task)
14
+ n = config.count
15
+ i = 0
16
+ sleep(config.delay) if config.delay
17
+ loop do
18
+ msg = read_next
19
+ break unless msg
20
+ msg = eval_send_expr(msg)
21
+ next unless msg
22
+ reply = request_and_receive(msg)
23
+ break if reply.nil?
24
+ output(eval_recv_expr(reply))
25
+ i += 1
26
+ break if n && n > 0 && i >= n
27
+ break if !config.interval && (config.data || config.file)
28
+ wait_for_interval if config.interval
29
+ end
30
+ end
31
+
32
+
33
+ def request_and_receive(msg)
34
+ return nil if msg.empty?
35
+ msg = [Marshal.dump(msg.first)] if config.format == :marshal
36
+ msg = @fmt.compress(msg)
37
+ reply_body = @sock.send_request(msg.first)
38
+ transient_ready!
39
+ return nil if reply_body.nil?
40
+ reply = @fmt.decompress([reply_body])
41
+ reply = [Marshal.load(reply.first)] if config.format == :marshal
42
+ reply
43
+ end
44
+
45
+
46
+ def wait_for_interval
47
+ wait = config.interval - (Time.now.to_f % config.interval)
48
+ sleep(wait) if wait > 0
49
+ end
50
+ end
51
+
52
+
53
+ # Runner for REP sockets (synchronous request-reply server).
54
+ #
55
+ # nnq's REP enforces strict alternation: #receive then #send_reply.
56
+ # There is no #send at all, so we bypass BaseRunner's send helpers.
57
+ class RepRunner < BaseRunner
58
+ private
59
+
60
+
61
+ def run_loop(task)
62
+ n = config.count
63
+ i = 0
64
+ loop do
65
+ msg = recv_msg
66
+ break if msg.nil?
67
+ break unless handle_rep_request(msg)
68
+ i += 1
69
+ break if n && n > 0 && i >= n
70
+ end
71
+ end
72
+
73
+
74
+ def handle_rep_request(msg)
75
+ if config.recv_expr || @recv_eval_proc
76
+ reply = eval_recv_expr(msg)
77
+ unless reply.equal?(SENT)
78
+ output(reply)
79
+ send_reply(reply || [""])
80
+ end
81
+ elsif config.echo
82
+ output(msg)
83
+ send_reply(msg)
84
+ elsif config.data || config.file || !config.stdin_is_tty
85
+ reply = read_next
86
+ return false unless reply
87
+ output(msg)
88
+ send_reply(reply)
89
+ else
90
+ abort "REP needs a reply source: --echo, --data, --file, -e, or stdin pipe"
91
+ end
92
+ true
93
+ end
94
+
95
+
96
+ def send_reply(msg)
97
+ return if msg.empty?
98
+ msg = [Marshal.dump(msg.first)] if config.format == :marshal
99
+ msg = @fmt.compress(msg)
100
+ @sock.send_reply(msg.first)
101
+ transient_ready!
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ module CLI
5
+ # Stateless helper for socket construction and configuration.
6
+ # All methods are module-level so callers compose rather than inherit.
7
+ module SocketSetup
8
+ # Default high water mark applied when the user does not pass
9
+ # --hwm. 64 matches the send pump's per-fairness-batch limit
10
+ # (one batch exactly fills a full queue).
11
+ DEFAULT_HWM = 64
12
+
13
+ # Default max inbound message size (1 MiB) so a misconfigured or
14
+ # malicious peer can't force arbitrary memory allocation on a
15
+ # terminal user. Users can raise it with --recv-maxsz N, or
16
+ # disable it entirely with --recv-maxsz 0.
17
+ DEFAULT_RECV_MAXSZ = 1 << 20
18
+
19
+ # Apply post-construction socket options from +config+ to +sock+.
20
+ # send_hwm and linger are construction-time kwargs (see {.build});
21
+ # the rest of the options are set here and read later by the
22
+ # engine/transports.
23
+ def self.apply_options(sock, config)
24
+ sock.options.read_timeout = config.timeout if config.timeout
25
+ sock.options.write_timeout = config.timeout if config.timeout
26
+ sock.options.reconnect_interval = config.reconnect_ivl if config.reconnect_ivl
27
+ sock.options.max_message_size =
28
+ case config.recv_maxsz
29
+ when nil then DEFAULT_RECV_MAXSZ
30
+ when 0 then nil
31
+ else config.recv_maxsz
32
+ end
33
+ end
34
+
35
+
36
+ # Create and fully configure a socket from +klass+ and +config+.
37
+ # nnq's Socket constructor takes linger + send_hwm directly
38
+ # (send_hwm is captured during routing init and can't be changed
39
+ # after the fact), so we pass them there.
40
+ def self.build(klass, config)
41
+ sock = klass.new(
42
+ linger: config.linger,
43
+ send_hwm: config.send_hwm || DEFAULT_HWM,
44
+ )
45
+ apply_options(sock, config)
46
+ sock
47
+ end
48
+
49
+
50
+ # Bind/connect +sock+ using URL strings from +config.binds+ / +config.connects+.
51
+ # +verbose+ is the integer verbosity level (0 = silent).
52
+ def self.attach(sock, config, verbose: 0)
53
+ config.binds.each do |url|
54
+ sock.bind(url)
55
+ CLI::Term.write_attach(:bind, sock.last_endpoint, verbose) if verbose >= 1
56
+ end
57
+ config.connects.each do |url|
58
+ sock.connect(url)
59
+ CLI::Term.write_attach(:connect, url, verbose) if verbose >= 1
60
+ end
61
+ end
62
+
63
+
64
+ # Bind/connect +sock+ from an Array of Endpoint objects.
65
+ # Used by PipeRunner, which works with structured endpoint lists.
66
+ # +verbose+ is the integer verbosity level (0 = silent).
67
+ def self.attach_endpoints(sock, endpoints, verbose: 0)
68
+ endpoints.each do |ep|
69
+ if ep.bind?
70
+ sock.bind(ep.url)
71
+ CLI::Term.write_attach(:bind, sock.last_endpoint, verbose) if verbose >= 1
72
+ else
73
+ sock.connect(ep.url)
74
+ CLI::Term.write_attach(:connect, ep.url, verbose) if verbose >= 1
75
+ end
76
+ end
77
+ end
78
+
79
+
80
+ # Subscribe to prefixes on a SUB socket.
81
+ #
82
+ # Unlike ZeroMQ, nng's sub0 starts with an empty subscription set,
83
+ # meaning *no* messages match. If the user passed no `--subscribe`
84
+ # flags, default to subscribing to the empty prefix so the CLI
85
+ # feels like `nngcat` / `omq sub`: receive everything by default.
86
+ def self.setup_subscriptions(sock, config)
87
+ return unless config.type_name == "sub"
88
+ prefixes = config.subscribes.empty? ? [""] : config.subscribes
89
+ prefixes.each { |p| sock.subscribe(p) }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ module CLI
5
+ # Runner for SURVEYOR sockets (broadcast survey, collect replies).
6
+ #
7
+ # Sends each input line as a survey, then collects replies until
8
+ # the survey window expires.
9
+ class SurveyorRunner < BaseRunner
10
+ private
11
+
12
+
13
+ def run_loop(task)
14
+ n = config.count
15
+ i = 0
16
+ sleep(config.delay) if config.delay
17
+ loop do
18
+ msg = read_next
19
+ break unless msg
20
+ msg = eval_send_expr(msg)
21
+ next unless msg
22
+ survey_and_collect(msg)
23
+ i += 1
24
+ break if n && n > 0 && i >= n
25
+ break if !config.interval && (config.data || config.file)
26
+ wait_for_interval if config.interval
27
+ end
28
+ end
29
+
30
+
31
+ def survey_and_collect(msg)
32
+ return if msg.empty?
33
+ msg = [Marshal.dump(msg.first)] if config.format == :marshal
34
+ msg = @fmt.compress(msg)
35
+ @sock.send_survey(msg.first)
36
+ transient_ready!
37
+ collect_replies
38
+ end
39
+
40
+
41
+ def collect_replies
42
+ loop do
43
+ body = @sock.receive
44
+ break if body.nil?
45
+ reply = @fmt.decompress([body])
46
+ reply = [Marshal.load(reply.first)] if config.format == :marshal
47
+ output(eval_recv_expr(reply))
48
+ rescue NNQ::TimedOut
49
+ break
50
+ end
51
+ end
52
+
53
+
54
+ def wait_for_interval
55
+ wait = config.interval - (Time.now.to_f % config.interval)
56
+ sleep(wait) if wait > 0
57
+ end
58
+ end
59
+
60
+
61
+ # Runner for RESPONDENT sockets (receive surveys, send replies).
62
+ #
63
+ # Mirrors REP: strict alternation of #receive then #send_reply.
64
+ class RespondentRunner < BaseRunner
65
+ private
66
+
67
+
68
+ def run_loop(task)
69
+ n = config.count
70
+ i = 0
71
+ loop do
72
+ msg = recv_msg
73
+ break if msg.nil?
74
+ break unless handle_survey(msg)
75
+ i += 1
76
+ break if n && n > 0 && i >= n
77
+ end
78
+ end
79
+
80
+
81
+ def handle_survey(msg)
82
+ if config.recv_expr || @recv_eval_proc
83
+ reply = eval_recv_expr(msg)
84
+ unless reply.equal?(SENT)
85
+ output(reply)
86
+ send_reply(reply || [""])
87
+ end
88
+ elsif config.echo
89
+ output(msg)
90
+ send_reply(msg)
91
+ elsif config.data || config.file || !config.stdin_is_tty
92
+ reply = read_next
93
+ return false unless reply
94
+ output(msg)
95
+ send_reply(reply)
96
+ else
97
+ abort "RESPONDENT needs a reply source: --echo, --data, --file, -e, or stdin pipe"
98
+ end
99
+ true
100
+ end
101
+
102
+
103
+ def send_reply(msg)
104
+ return if msg.empty?
105
+ msg = [Marshal.dump(msg.first)] if config.format == :marshal
106
+ msg = @fmt.compress(msg)
107
+ @sock.send_reply(msg.first)
108
+ transient_ready!
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ module CLI
5
+ # Stateless terminal formatting and stderr writing helpers shared
6
+ # by every code path that emits verbose-driven log lines (event
7
+ # monitor callbacks in BaseRunner / PipeRunner, SocketSetup attach
8
+ # helpers, parallel/pipe Ractor workers).
9
+ #
10
+ # Pure module functions: no state, no instance, safe to call from
11
+ # any thread or Ractor.
12
+ #
13
+ module Term
14
+ module_function
15
+
16
+
17
+ # Returns a stderr log line prefix. At verbose >= 4, prepends an
18
+ # ISO8601 UTC timestamp with µs precision so log traces become
19
+ # time-correlatable. Otherwise returns the empty string.
20
+ #
21
+ # @param verbose [Integer]
22
+ # @return [String]
23
+ def log_prefix(verbose)
24
+ return "" unless verbose && verbose >= 4
25
+ "#{Time.now.utc.strftime("%FT%T.%6N")}Z "
26
+ end
27
+
28
+
29
+ # Formats one NNQ::MonitorEvent into a single log line (no
30
+ # trailing newline).
31
+ #
32
+ # @param event [NNQ::MonitorEvent]
33
+ # @param verbose [Integer]
34
+ # @return [String]
35
+ def format_event(event, verbose)
36
+ prefix = log_prefix(verbose)
37
+ case event.type
38
+ when :message_sent
39
+ "#{prefix}nnq: >> #{Formatter.preview([event.detail[:body]])}"
40
+ when :message_received
41
+ "#{prefix}nnq: << #{Formatter.preview([event.detail[:body]])}"
42
+ else
43
+ ep = event.endpoint ? " #{event.endpoint}" : ""
44
+ detail = event.detail ? " #{event.detail}" : ""
45
+ "#{prefix}nnq: #{event.type}#{ep}#{detail}"
46
+ end
47
+ end
48
+
49
+
50
+ # Formats an "attached endpoint" log line (Bound to / Connecting to).
51
+ #
52
+ # @param kind [:bind, :connect]
53
+ # @param url [String]
54
+ # @param verbose [Integer]
55
+ # @return [String]
56
+ def format_attach(kind, url, verbose)
57
+ verb = kind == :bind ? "Bound to" : "Connecting to"
58
+ "#{log_prefix(verbose)}nnq: #{verb} #{url}"
59
+ end
60
+
61
+
62
+ # Writes one formatted event line to +io+ (default $stderr).
63
+ #
64
+ # @param event [NNQ::MonitorEvent]
65
+ # @param verbose [Integer]
66
+ # @param io [#write] writable sink, default $stderr
67
+ # @return [void]
68
+ def write_event(event, verbose, io: $stderr)
69
+ io.write("#{format_event(event, verbose)}\n")
70
+ end
71
+
72
+
73
+ # Writes one "Bound to / Connecting to" line to +io+
74
+ # (default $stderr).
75
+ #
76
+ # @param kind [:bind, :connect]
77
+ # @param url [String]
78
+ # @param verbose [Integer]
79
+ # @param io [#write]
80
+ # @return [void]
81
+ def write_attach(kind, url, verbose, io: $stderr)
82
+ io.write("#{format_attach(kind, url, verbose)}\n")
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ module CLI
5
+ # Monitors peer-disconnect events for --transient mode.
6
+ #
7
+ # Starts an async task that waits until #ready! is called (signalling
8
+ # that at least one message has been exchanged), then waits for all
9
+ # peers to disconnect, disables reconnection, and either stops the
10
+ # task (send-only) or closes the read side of the socket (recv side).
11
+ #
12
+ class TransientMonitor
13
+ # @param sock [Object] the NNQ socket to monitor
14
+ # @param config [Config] frozen CLI configuration
15
+ # @param task [Async::Task] parent async task
16
+ # @param log_fn [Method] callable for verbose logging
17
+ def initialize(sock, config, task, log_fn)
18
+ @barrier = Async::Promise.new
19
+ task.async do
20
+ @barrier.wait
21
+ sock.all_peers_gone.wait unless sock.connection_count == 0
22
+ log_fn.call("All peers disconnected, exiting")
23
+ sock.reconnect_enabled = false
24
+ if config.send_only?
25
+ task.stop
26
+ else
27
+ sock.close_read
28
+ end
29
+ end
30
+ end
31
+
32
+
33
+ # Signal that the first message has been sent or received.
34
+ # Idempotent -- safe to call multiple times.
35
+ #
36
+ def ready!
37
+ @barrier.resolve(true) unless @barrier.resolved?
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ module CLI
5
+ VERSION = "0.2.0"
6
+ end
7
+ end
data/lib/nnq/cli.rb ADDED
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "cli/version"
5
+ require_relative "cli/config"
6
+ require_relative "cli/cli_parser"
7
+ require_relative "cli/formatter"
8
+ require_relative "cli/expression_evaluator"
9
+ require_relative "cli/socket_setup"
10
+ require_relative "cli/term"
11
+ require_relative "cli/transient_monitor"
12
+ require_relative "cli/base_runner"
13
+ require_relative "cli/push_pull"
14
+ require_relative "cli/pub_sub"
15
+ require_relative "cli/req_rep"
16
+ require_relative "cli/pair"
17
+ require_relative "cli/bus"
18
+ require_relative "cli/surveyor_respondent"
19
+ require_relative "cli/ractor_helpers"
20
+ require_relative "cli/pipe_worker"
21
+ require_relative "cli/pipe"
22
+
23
+ module NNQ
24
+
25
+ class << self
26
+ # @return [Proc, nil] registered outgoing message transform
27
+ attr_reader :outgoing_proc
28
+ # @return [Proc, nil] registered incoming message transform
29
+ attr_reader :incoming_proc
30
+
31
+ # Registers an outgoing message transform (used by -r scripts).
32
+ #
33
+ # @yield [Array<String>] 1-element message array before sending
34
+ # @return [Proc]
35
+ def outgoing(&block) = @outgoing_proc = block
36
+
37
+ # Registers an incoming message transform (used by -r scripts).
38
+ #
39
+ # @yield [Array<String>] 1-element message array after receiving
40
+ # @return [Proc]
41
+ def incoming(&block) = @incoming_proc = block
42
+ end
43
+
44
+
45
+ # Command-line interface for NNQ socket operations.
46
+ module CLI
47
+ SOCKET_TYPE_NAMES = %w[req rep pub sub push pull pair bus surveyor respondent pipe].freeze
48
+
49
+
50
+ RUNNER_MAP = {
51
+ "push" => [PushRunner, :PUSH0],
52
+ "pull" => [PullRunner, :PULL0],
53
+ "pub" => [PubRunner, :PUB0],
54
+ "sub" => [SubRunner, :SUB0],
55
+ "req" => [ReqRunner, :REQ0],
56
+ "rep" => [RepRunner, :REP0],
57
+ "pair" => [PairRunner, :PAIR0],
58
+ "bus" => [BusRunner, :BUS0],
59
+ "surveyor" => [SurveyorRunner, :SURVEYOR0],
60
+ "respondent" => [RespondentRunner, :RESPONDENT0],
61
+ "pipe" => [PipeRunner, nil],
62
+ }.freeze
63
+
64
+
65
+ module_function
66
+
67
+
68
+ # Displays text through the system pager, or prints directly
69
+ # when stdout is not a terminal.
70
+ #
71
+ def page(text)
72
+ if $stdout.tty?
73
+ if ENV["PAGER"]
74
+ pager = ENV["PAGER"]
75
+ else
76
+ ENV["LESS"] ||= "-FR"
77
+ pager = "less"
78
+ end
79
+ IO.popen(pager, "w") { |io| io.puts text }
80
+ else
81
+ puts text
82
+ end
83
+ rescue Errno::ENOENT
84
+ puts text
85
+ rescue Errno::EPIPE
86
+ # user quit pager early
87
+ end
88
+
89
+
90
+ # Main entry point.
91
+ #
92
+ # @param argv [Array<String>] command-line arguments
93
+ # @return [void]
94
+ def run(argv = ARGV)
95
+ run_socket(argv)
96
+ end
97
+
98
+
99
+ # Parses CLI arguments, validates options, and runs the main
100
+ # event loop inside an Async reactor.
101
+ #
102
+ def run_socket(argv)
103
+ config = build_config(argv)
104
+
105
+ require "nnq"
106
+ require "async"
107
+ require "json"
108
+ require "console"
109
+
110
+ CliParser.validate_gems!(config)
111
+ trap("INT") { Process.exit!(0) }
112
+ trap("TERM") { Process.exit!(0) }
113
+
114
+ Console.logger = Console::Logger.new(Console::Output::Null.new) unless config.verbose >= 1
115
+
116
+ debug_ep = nil
117
+
118
+ if ENV["NNQ_DEBUG_URI"]
119
+ begin
120
+ require "async/debug"
121
+ debug_ep = Async::HTTP::Endpoint.parse(ENV["NNQ_DEBUG_URI"])
122
+ if debug_ep.scheme == "https"
123
+ require "localhost"
124
+ debug_ep = Async::HTTP::Endpoint.parse(ENV["NNQ_DEBUG_URI"],
125
+ ssl_context: Localhost::Authority.fetch.server_context)
126
+ end
127
+ rescue LoadError
128
+ abort "NNQ_DEBUG_URI requires the async-debug gem: gem install async-debug"
129
+ end
130
+ end
131
+
132
+ if config.type_name.nil?
133
+ Process.setproctitle("nnq script")
134
+ Object.include(NNQ) unless Object.include?(NNQ)
135
+ Async annotation: 'nnq' do
136
+ Async::Debug.serve(endpoint: debug_ep) if debug_ep
137
+ config.scripts.each { |s| load_script(s) }
138
+ rescue => e
139
+ $stderr.puts "nnq: #{e.message}"
140
+ exit 1
141
+ end
142
+ return
143
+ end
144
+
145
+ runner_class, socket_sym = RUNNER_MAP.fetch(config.type_name)
146
+
147
+ Async annotation: "nnq #{config.type_name}" do |task|
148
+ Async::Debug.serve(endpoint: debug_ep) if debug_ep
149
+ config.scripts.each { |s| load_script(s) }
150
+ runner = if socket_sym
151
+ runner_class.new(config, NNQ.const_get(socket_sym))
152
+ else
153
+ runner_class.new(config)
154
+ end
155
+ runner.call(task)
156
+ rescue DecompressError => e
157
+ $stderr.puts "nnq: #{e.message}"
158
+ exit 1
159
+ rescue IO::TimeoutError, Async::TimeoutError
160
+ $stderr.puts "nnq: timeout" unless config.quiet
161
+ exit 2
162
+ rescue ::Socket::ResolutionError => e
163
+ $stderr.puts "nnq: #{e.message}"
164
+ exit 1
165
+ end
166
+ end
167
+
168
+
169
+ def load_script(s)
170
+ if s == :stdin
171
+ eval($stdin.read, TOPLEVEL_BINDING, "(stdin)", 1) # rubocop:disable Security/Eval
172
+ else
173
+ require s
174
+ end
175
+ end
176
+ private_class_method :load_script
177
+
178
+
179
+ # Builds a frozen Config from command-line arguments.
180
+ #
181
+ def build_config(argv)
182
+ opts = CliParser.parse(argv)
183
+ CliParser.validate!(opts)
184
+
185
+ opts[:stdin_is_tty] = $stdin.tty?
186
+
187
+ Ractor.make_shareable(Config.new(**opts))
188
+ end
189
+ end
190
+ end