omq-cli 0.13.0 → 0.14.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: 0135d1ab507a4e82186cae115a477ccbd4b231e12a7450cb8989a5c0c1479e50
4
- data.tar.gz: efc832159dc103ec4b18ba5f65acb5976a09c7530b246e5e433795357ca21181
3
+ metadata.gz: b5a558fd06799fcf4bee5d5036a4cac86c8482554df3998e5f42171d04e520db
4
+ data.tar.gz: e53bb5236bb5bc660e04ad00c10aa0f3b1e4a848ed9d74363c3943f877bf210f
5
5
  SHA512:
6
- metadata.gz: a8093420e5209f9e4672dd15a343c60167959532d90fda0fce7f108042807a65d27f033eb39a3abfee80059e8832d2e90680a624f36575c8b608f2da5095865c
7
- data.tar.gz: 30dc24c50221b726130e638764d891d2736044b358b364f2bd987a21eceac85bea44ed0821ab601d1070d2e60bc10492a61ffd8baa8129588a0dce06b72ec192
6
+ metadata.gz: 5841f42d980388be4ce4d0feea88b76a358e86f6ba6e64f6b1775dfdcad16dc349ba3866c824fa74ffb88005876e64c027de9fd93fa21417c8a5a53be3180861
7
+ data.tar.gz: 4b68d2c56882c87a06e75fa348eff4f9df75d5cd0c3a85c26e20e6676780c61c0413e329dff7bdbb082b861795cdf09bb30aa4b61c7279c88bbd2e29373d1552
data/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0 — 2026-04-13
4
+
5
+ ### Added
6
+
7
+ - **Receive-capable sockets decompress by default.** All socket types
8
+ except pure senders (`push`, `pub`, `scatter`, `radio`) now advertise
9
+ the ZMTP-Zstd profile in **passive mode** at startup, so they accept
10
+ compressed frames from any active-sender peer without requiring
11
+ `-z` on the receive side. They never compress their own outgoing
12
+ frames in this mode — use `-z` / `-Z` / `--compress=LEVEL` on the
13
+ sender to opt it in. A `push` piped into a `pull` with no flags on
14
+ either side stays uncompressed; `omq push -z | omq pull` compresses
15
+ on the wire and the pull side decodes transparently. This is the
16
+ RFC Sec. 6.4 "Passive senders" mode; requires omq-rfc-zstd >= 0.1.0.
17
+ - **`-Z` flag for better-ratio compression (zstd level 3).** `-z`
18
+ remains the fast default (level -3) and `--compress=LEVEL` takes
19
+ a custom zstd level (e.g. `--compress=19`, `--compress=-1`). Short
20
+ bundling (`-zvvv`, `-Zvvv`) still works.
21
+ - **`-vvv` logs `ZDICT` exchange.** When the auto-trained dictionary
22
+ is shipped/received, the trace prints `>> ZDICT (NB)` on the sender
23
+ and `<< ZDICT (NB)` on the receiver.
24
+ - **`-vvv` wire-size annotation for compressed traces.** Message
25
+ previews on compressed sockets include the post-compression byte
26
+ count: `(280B wire=29B) ZZ…`. Plumbed from the ZMTP-Zstd wrapper
27
+ through the engine's verbose monitor.
28
+
29
+ ### Changed
30
+
31
+ - **Compression backend switched from `rlz4` to `omq-rfc-zstd`.**
32
+ Compression is now a ZMTP wire-protocol extension negotiated via
33
+ the `X-Compression` READY property and applied below the
34
+ application API. Auto-trained dictionaries are shipped over a
35
+ `ZDICT` command frame once the sender has enough samples. The
36
+ `Formatter` no longer compresses or decompresses anything — it
37
+ only encodes/decodes wire formats. Pipe `-z` is no longer modal
38
+ (`compress_in`/`compress_out` removed) since compression is a
39
+ per-socket, send-side property negotiated with each peer.
40
+ - **`-vvv` output ordering under compression.** At `-vvv`, the
41
+ monitor fiber now writes both the trace line and the plaintext
42
+ body, so trace-and-body pairs land on the tty in order instead of
43
+ interleaving between the recv pump and the app fiber.
44
+ - **TCP host normalization moved into `OMQ::Transport::TCP`.** `omq`
45
+ v0.19.0 now handles `tcp://*:PORT`, `tcp://:PORT`, and
46
+ `tcp://localhost:PORT` natively (including dual-stack `*` binding
47
+ both IPv4 and IPv6 wildcards), so `CliParser` no longer rewrites
48
+ these URLs before handing them off. Removed
49
+ `CliParser.loopback_bind_host` and the `normalize_bind`/
50
+ `normalize_connect`/`normalize_ep` block. Requires `omq ~> 0.19`.
51
+ - **Terminate on protocol errors instead of silent reconnect.** When
52
+ a peer sends a frame that violates the ZMTP wire protocol
53
+ (oversized, bad framing, zstd bytebomb, nonce exhaustion, …), the
54
+ library drops that one connection and reconnects — the libzmq
55
+ parity behavior. The CLI is a different audience: a persistent
56
+ protocol violation is almost always a misconfiguration the user
57
+ needs to see, not silently paper over. Every runner
58
+ (`BaseRunner`, `PipeRunner`, `ParallelWorker`, `PipeWorker`) now
59
+ attaches a monitor that watches for `:disconnected` events whose
60
+ `detail[:error]` is a `Protocol::ZMTP::Error`, prints
61
+ `omq: <reason>` to stderr, kills the socket, and exits with
62
+ status 1. Requires `omq ~> 0.19.2` for the new `:disconnected`
63
+ detail shape and `Socket#engine` accessor.
64
+ - **`-vvv` disconnect events render the reason in parentheses.**
65
+ `Term.format_event` now pretty-prints `:disconnected` details
66
+ that contain a `:reason` key, e.g.
67
+ `disconnected tcp://:5555 (frame size 1024 exceeds max_message_size 32)`,
68
+ instead of dumping the raw hash.
69
+
70
+ ### Fixed
71
+
72
+ - **`--recv-maxsz` is now actually applied in pipe and parallel
73
+ modes.** `PipeRunner`, `PipeWorker`, and `ParallelWorker` were
74
+ only calling `SocketSetup.apply_options` + `apply_compression`
75
+ and skipping `max_message_size` entirely — so the default 1 MiB
76
+ cap (and any `--recv-maxsz` override) silently had no effect on
77
+ `omq pipe` or `omq pull -P`. Extracted the logic into
78
+ `SocketSetup.apply_recv_maxsz` and wired it into all four setup
79
+ paths (sequential pull/rep, sequential pipe, parallel worker,
80
+ pipe worker). Oversized frames now drop the connection as
81
+ intended and — combined with the CLI termination policy above —
82
+ exit with a clear error instead of hanging in a reconnect loop.
83
+
3
84
  ## 0.13.0 — 2026-04-12
4
85
 
5
86
  ### Added
data/README.md CHANGED
@@ -348,8 +348,16 @@ omq pull -b tcp://:5557 -t 5
348
348
 
349
349
  ## Compression
350
350
 
351
- Both sides must use `--compress` (`-z`). Uses LZ4 frame format, provided
352
- by the `rlz4` gem (Ractor-safe, Rust extension via `lz4_flex`).
351
+ Set `--compress` (`-z`) on either or both sides. The flag enables
352
+ ZMTP-Zstd (provided by `omq-rfc-zstd`), a wire-protocol extension
353
+ that negotiates Zstandard compression during the ZMTP handshake via
354
+ an `X-Compression` READY metadata field. If both peers advertise it,
355
+ each side compresses its outgoing frames; if only one side does, the
356
+ connection stays plaintext (no error). The extension uses the
357
+ auto-trained dictionary mode: the sender feeds the first messages
358
+ into a dictionary trainer, ships the trained dictionary over a
359
+ ZDICT command frame, then switches to dict-bound compression for
360
+ the rest of the connection.
353
361
 
354
362
  ```sh
355
363
  omq push -c tcp://remote:5557 -z < data.txt
@@ -15,7 +15,7 @@ module OMQ
15
15
  def initialize(config, socket_class)
16
16
  @config = config
17
17
  @klass = socket_class
18
- @fmt = Formatter.new(config.format, compress: config.compress)
18
+ @fmt = Formatter.new(config.format)
19
19
  end
20
20
 
21
21
 
@@ -26,13 +26,17 @@ module OMQ
26
26
  def call(task)
27
27
  set_process_title
28
28
  setup_socket
29
- start_event_monitor if config.verbose >= 2
29
+ start_event_monitor
30
30
  maybe_start_transient_monitor(task)
31
31
  sleep(config.delay) if config.delay && config.recv_only?
32
32
  wait_for_peer if needs_peer_wait?
33
33
  run_begin_blocks
34
34
  run_loop(task)
35
35
  run_end_blocks
36
+ rescue OMQ::SocketDeadError => error
37
+ reason = error.cause&.message || error.message
38
+ $stderr.write("omq: #{reason}\n")
39
+ exit 1
36
40
  ensure
37
41
  @sock&.close
38
42
  end
@@ -313,41 +317,21 @@ module OMQ
313
317
 
314
318
  def send_msg(parts)
315
319
  return if parts.empty?
316
- log_parts = parts
317
320
  parts = [Marshal.dump(parts)] if config.format == :marshal
318
- if config.compress
319
- wire = @fmt.compress(parts)
320
- trace_msg(">>", log_parts, wire)
321
- @sock.send(wire)
322
- else
323
- @sock.send(parts)
324
- end
321
+ @sock.send(parts)
325
322
  transient_ready!
326
323
  end
327
324
 
328
325
 
329
326
  def recv_msg
330
- raw = @sock.receive
331
- return nil if raw.nil?
332
- parts = @fmt.decompress(raw)
333
- trace_msg("<<", parts, raw) if config.compress
327
+ parts = @sock.receive
328
+ return nil if parts.nil?
334
329
  parts = Marshal.load(parts.first) if config.format == :marshal
335
330
  transient_ready!
336
331
  parts
337
332
  end
338
333
 
339
334
 
340
- # Writes an explicit -vvv message trace line. Used when compression
341
- # is active: the engine-level monitor would see compressed bytes,
342
- # so we log post-decompression (recv) / pre-compression (send)
343
- # and annotate the compressed wire size.
344
- def trace_msg(marker, parts, wire)
345
- return unless config.verbose >= 3
346
- wire_size = wire.sum(&:bytesize)
347
- $stderr.write("#{Term.log_prefix(config.timestamps)}omq: #{marker} #{Formatter.preview(parts, wire_size: wire_size)}\n")
348
- end
349
-
350
-
351
335
  def recv_msg_raw
352
336
  msg = @sock.receive
353
337
  msg&.dup
@@ -408,6 +392,10 @@ module OMQ
408
392
 
409
393
  def output(parts)
410
394
  return if config.quiet || parts.nil?
395
+ # At -vvv, the monitor fiber owns both the trace and the body
396
+ # output (see start_event_monitor). Skipping the app-side write
397
+ # avoids interleaving between the two fibers on a shared tty.
398
+ return if config.verbose >= 3
411
399
  $stdout.write(@fmt.encode(parts))
412
400
  $stdout.flush
413
401
  end
@@ -480,17 +468,38 @@ module OMQ
480
468
  end
481
469
 
482
470
 
483
- # -vv: log connect/disconnect/retry/timeout events via Socket#monitor
484
- # -vvv: also log message sent/received traces
471
+ # Always attached so protocol-level disconnect events can kill
472
+ # the socket. Verbose gating lives inside the callback:
473
+ # -vv log connect/disconnect/retry/timeout events
474
+ # -vvv also log message sent/received traces
485
475
  # --timestamps[=s|ms|us]: prepend UTC timestamps to log lines
486
476
  def start_event_monitor
487
- # When compress is on, BaseRunner#trace_msg logs post-
488
- # decompression, so we suppress the engine-level message trace.
489
- trace = config.verbose >= 3 && !config.compress
477
+ trace = config.verbose >= 3
478
+ log_events = config.verbose >= 2
490
479
  @sock.monitor(verbose: trace) do |event|
491
- Term.write_event(event, config.timestamps)
480
+ Term.write_event(event, config.timestamps) if log_events
481
+ if trace && event.type == :message_received && !config.quiet
482
+ $stdout.write(@fmt.encode(event.detail[:parts]))
483
+ $stdout.flush
484
+ end
485
+ kill_on_protocol_error(event)
492
486
  end
493
487
  end
488
+
489
+
490
+ # omq-cli policy: a peer that commits a protocol-level violation
491
+ # (Protocol::ZMTP::Error — oversized frame, decompression
492
+ # bytebomb, bad framing, …) is almost certainly a
493
+ # misconfiguration the user needs to see. Mark the socket dead
494
+ # so the next receive raises SocketDeadError. The library
495
+ # itself just drops the connection and keeps serving the
496
+ # others; this stricter policy is CLI-only.
497
+ def kill_on_protocol_error(event)
498
+ return unless event.type == :disconnected
499
+ error = event.detail && event.detail[:error]
500
+ return unless error.is_a?(Protocol::ZMTP::Error)
501
+ @sock.engine.signal_fatal_error(error)
502
+ end
494
503
  end
495
504
  end
496
505
  end
@@ -125,8 +125,12 @@ module OMQ
125
125
 
126
126
  -- Compression ----------------------------------------------
127
127
 
128
- # both sides must use --compress/-z
129
- omq pull --bind tcp://:5557 --compress &
128
+ # ZMTP-Zstd is negotiated transparently during the handshake.
129
+ # Receive-capable sockets (pull, sub, rep, ...) advertise the
130
+ # profile by default in passive mode: they decode compressed
131
+ # frames from an active sender but never compress their own
132
+ # outgoing frames. Use -z / -Z on the sender to opt it in.
133
+ omq pull --bind tcp://:5557 &
130
134
  echo "compressible data" | omq push --connect tcp://localhost:5557 -z
131
135
 
132
136
  -- CURVE Encryption -----------------------------------------
@@ -225,8 +229,7 @@ module OMQ
225
229
  rcvbuf: nil,
226
230
  conflate: false,
227
231
  compress: false,
228
- compress_in: false,
229
- compress_out: false,
232
+ compress_level: nil,
230
233
  send_expr: nil,
231
234
  recv_expr: nil,
232
235
  parallel: nil,
@@ -386,16 +389,17 @@ module OMQ
386
389
  o.on("--conflate", "Keep only last message per subscriber (PUB/RADIO)") { opts[:conflate] = true }
387
390
 
388
391
  o.separator "\nCompression:"
389
- o.on("-z", "--compress", "LZ4 compression per frame (modal with --in/--out)") do
390
- require "rlz4"
391
- case pipe_side
392
- when :in
393
- opts[:compress_in] = true
394
- when :out
395
- opts[:compress_out] = true
396
- else
397
- opts[:compress] = true
398
- end
392
+ o.on("-z", "Zstd compression (level -3, fast)") do
393
+ opts[:compress] = true
394
+ opts[:compress_level] = -3
395
+ end
396
+ o.on("-Z", "Zstd compression (level 3, better ratio)") do
397
+ opts[:compress] = true
398
+ opts[:compress_level] = 3
399
+ end
400
+ o.on("--compress=LEVEL", Integer, "Zstd compression with custom level (e.g. 19, -1)") do |v|
401
+ opts[:compress] = true
402
+ opts[:compress_level] = v
399
403
  end
400
404
 
401
405
  o.separator "\nProcessing (-e = incoming, -E = outgoing):"
@@ -470,26 +474,9 @@ module OMQ
470
474
  opts[:type_name] = type_name.downcase
471
475
  end
472
476
 
473
- # Normalize shorthand hostnames to concrete addresses.
474
- #
475
- # Binds: tcp://:PORT → loopback (::1 if IPv6 available, else 127.0.0.1)
476
- # tcp://*:PORT → 0.0.0.0 (all interfaces, IPv4)
477
- # tcp://0.0.0.0:PORT, tcp://[::]:PORT → pass through
478
- #
479
- # Connects: tcp://:PORT → localhost (Happy Eyeballs)
480
- # tcp://*:PORT → localhost
481
- #
482
- # The hang on macOS (IPv6 connect(2) not getting ECONNREFUSED via
483
- # kqueue) is fixed by the connect timeout in Engine::Reconnect.
484
- loopback = self.class.loopback_bind_host
485
- normalize_bind = ->(url) { url.sub(%r{\Atcp://\*:}, "tcp://0.0.0.0:").sub(%r{\Atcp://:}, "tcp://#{loopback}:") }
486
- normalize_connect = ->(url) { url.sub(%r{\Atcp://(\*|):}, "tcp://localhost:") }
487
- normalize_ep = ->(ep) { Endpoint.new(ep.bind? ? normalize_bind.call(ep.url) : normalize_connect.call(ep.url), ep.bind?) }
488
- opts[:binds].map!(&normalize_bind)
489
- opts[:connects].map!(&normalize_connect)
490
- opts[:endpoints].map!(&normalize_ep)
491
- opts[:in_endpoints].map!(&normalize_ep)
492
- opts[:out_endpoints].map!(&normalize_ep)
477
+ # Host shorthand (tcp://*:PORT, tcp://:PORT, tcp://localhost:PORT)
478
+ # is normalized inside OMQ::Transport::TCP — see its
479
+ # #normalize_bind_host / #normalize_connect_host / #loopback_host.
493
480
 
494
481
  opts
495
482
  end
@@ -592,19 +579,6 @@ module OMQ
592
579
  end
593
580
 
594
581
 
595
- # Returns the loopback address for bind normalization.
596
- # Prefers IPv6 loopback ([::1]) when the host has at least one
597
- # non-loopback, non-link-local IPv6 address, otherwise 127.0.0.1.
598
- #
599
- def self.loopback_bind_host
600
- @loopback_bind_host ||= begin
601
- has_ipv6 = ::Socket.getifaddrs.any? { |ifa|
602
- addr = ifa.addr
603
- addr&.ipv6? && !addr.ipv6_loopback? && !addr.ipv6_linklocal?
604
- }
605
- has_ipv6 ? "[::1]" : "127.0.0.1"
606
- end
607
- end
608
582
  end
609
583
  end
610
584
  end
@@ -25,8 +25,7 @@ module OMQ
25
25
  parts = recv_msg_raw
26
26
  break if parts.nil?
27
27
  routing_id = parts.shift
28
- body = @fmt.decompress(parts)
29
- break unless handle_server_request(routing_id, body)
28
+ break unless handle_server_request(routing_id, parts)
30
29
  i += 1
31
30
  break if n && n > 0 && i >= n
32
31
  end
@@ -37,15 +36,15 @@ module OMQ
37
36
  if config.recv_expr || @recv_eval_proc
38
37
  reply = eval_recv_expr(body)
39
38
  output([display_routing_id(routing_id), *(reply || [""])])
40
- @sock.send_to(routing_id, @fmt.compress(reply || [""]).first)
39
+ @sock.send_to(routing_id, (reply || [""]).first)
41
40
  elsif config.echo
42
41
  output([display_routing_id(routing_id), *body])
43
- @sock.send_to(routing_id, @fmt.compress(body).first || "")
42
+ @sock.send_to(routing_id, body.first || "")
44
43
  elsif config.data || config.file || !config.stdin_is_tty
45
44
  reply = read_next
46
45
  return false unless reply
47
46
  output([display_routing_id(routing_id), *body])
48
- @sock.send_to(routing_id, @fmt.compress(reply).first || "")
47
+ @sock.send_to(routing_id, reply.first || "")
49
48
  end
50
49
  true
51
50
  end
@@ -66,7 +65,6 @@ module OMQ
66
65
  parts = recv_msg_raw
67
66
  break if parts.nil?
68
67
  routing_id = parts.shift
69
- parts = @fmt.decompress(parts)
70
68
  result = eval_recv_expr([display_routing_id(routing_id), *parts])
71
69
  output(result)
72
70
  i += 1
@@ -77,7 +75,7 @@ module OMQ
77
75
 
78
76
 
79
77
  def send_to_peer(id, parts)
80
- @sock.send_to(id, @fmt.compress(parts).first || "")
78
+ @sock.send_to(id, parts.first || "")
81
79
  end
82
80
  end
83
81
  end
@@ -44,8 +44,7 @@ module OMQ
44
44
  :rcvbuf,
45
45
  :conflate,
46
46
  :compress,
47
- :compress_in,
48
- :compress_out,
47
+ :compress_level,
49
48
  :send_expr,
50
49
  :recv_expr,
51
50
  :parallel,
@@ -2,17 +2,14 @@
2
2
 
3
3
  module OMQ
4
4
  module CLI
5
- # Raised when LZ4 decompression fails.
6
- class DecompressError < RuntimeError; end
7
-
8
- # Handles encoding/decoding messages in the configured format,
9
- # plus optional LZ4 compression.
5
+ # Handles encoding/decoding messages in the configured format.
6
+ # Compression is handled below the application API by ZMTP-Zstd
7
+ # (omq-rfc-zstd) once enabled via +socket.compression=+; the
8
+ # formatter sees plaintext frames in both directions.
10
9
  class Formatter
11
10
  # @param format [Symbol] wire format (:ascii, :quoted, :raw, :jsonl, :msgpack, :marshal)
12
- # @param compress [Boolean] whether to apply LZ4 compression per frame
13
- def initialize(format, compress: false)
14
- @format = format
15
- @compress = compress
11
+ def initialize(format)
12
+ @format = format
16
13
  end
17
14
 
18
15
 
@@ -83,32 +80,11 @@ module OMQ
83
80
  end
84
81
 
85
82
 
86
- # Compresses each frame with LZ4 if compression is enabled.
87
- #
88
- # @param parts [Array<String>] message frames
89
- # @return [Array<String>] optionally compressed frames
90
- def compress(parts)
91
- @compress ? parts.map { |p| RLZ4.compress(p) if p } : parts
92
- end
93
-
94
-
95
- # Decompresses each frame with LZ4 if compression is enabled.
96
- # nil/empty frames pass through — they were nil before send coercion.
97
- #
98
- # @param parts [Array<String>] possibly compressed message frames
99
- # @return [Array<String>] decompressed frames
100
- def decompress(parts)
101
- @compress ? parts.map { |p| p && !p.empty? ? RLZ4.decompress(p) : p } : parts
102
- rescue RLZ4::DecompressError
103
- raise DecompressError, "decompression failed (did the sender use --compress?)"
104
- end
105
-
106
-
107
83
  # Formats message parts for human-readable preview (logging).
108
- # When +wire_size+ is given, the header also shows the
109
- # compressed on-the-wire size: "(29B wire=12B)".
84
+ # When +wire_size+ is given (ZMTP-Zstd negotiated), the header
85
+ # also shows the compressed on-the-wire size: "(29B wire=12B)".
110
86
  #
111
- # @param parts [Array<String>] message frames (decompressed)
87
+ # @param parts [Array<String>] plaintext message frames
112
88
  # @param wire_size [Integer, nil] compressed bytes on the wire
113
89
  # @return [String] truncated preview of each frame joined by |
114
90
  def self.preview(parts, wire_size: nil)
@@ -29,8 +29,15 @@ module OMQ
29
29
  compile_expr
30
30
  run_loop
31
31
  run_end_block
32
- rescue DecompressError => e
33
- @error_port.send(e.message)
32
+ rescue OMQ::SocketDeadError => error
33
+ # Socket was killed by a protocol violation on the peer side
34
+ # (see Engine#signal_fatal_error). Surface the underlying
35
+ # cause via the log stream and exit cleanly -- the Ractor
36
+ # completes, consumer threads unblock.
37
+ reason = error.cause&.message || error.message
38
+ @log_port.send("omq: #{reason}")
39
+ rescue => error
40
+ @error_port.send("#{error.class}: #{error.message}")
34
41
  ensure
35
42
  @sock&.close
36
43
  end
@@ -43,6 +50,8 @@ module OMQ
43
50
  def setup_socket
44
51
  @sock = @config.ffi ? OMQ.const_get(@socket_sym).new(backend: :ffi) : OMQ.const_get(@socket_sym).new
45
52
  OMQ::CLI::SocketSetup.apply_options(@sock, @config)
53
+ OMQ::CLI::SocketSetup.apply_recv_maxsz(@sock, @config)
54
+ OMQ::CLI::SocketSetup.apply_compression(@sock, @config, @config.type_name)
46
55
  @sock.identity = @config.identity if @config.identity
47
56
  OMQ::CLI::SocketSetup.attach_endpoints(@sock, @endpoints, verbose: 0)
48
57
  end
@@ -57,26 +66,23 @@ module OMQ
57
66
 
58
67
 
59
68
  def start_monitors
60
- return unless @config.verbose >= 2
61
- # When compress is on, messages are traced explicitly post-
62
- # decompression; suppress engine-level message trace to avoid
63
- # showing compressed bytes.
64
- trace = @config.verbose >= 3 && !@config.compress
69
+ trace = @config.verbose >= 3
70
+ log_events = @config.verbose >= 2
65
71
  @sock.monitor(verbose: trace) do |event|
66
- @log_port.send(OMQ::CLI::Term.format_event(event, @config.timestamps))
72
+ @log_port.send(OMQ::CLI::Term.format_event(event, @config.timestamps)) if log_events
73
+ kill_on_protocol_error(event)
67
74
  end
68
75
  end
69
76
 
70
77
 
71
- def trace_in(parts, wire)
72
- return unless @config.verbose >= 3 && @config.compress
73
- @log_port.send("#{OMQ::CLI::Term.log_prefix(@config.timestamps)}omq: << #{OMQ::CLI::Formatter.preview(parts, wire_size: wire.sum(&:bytesize))}")
74
- end
75
-
76
-
77
- def trace_out(parts, wire)
78
- return unless @config.verbose >= 3 && @config.compress
79
- @log_port.send("#{OMQ::CLI::Term.log_prefix(@config.timestamps)}omq: >> #{OMQ::CLI::Formatter.preview(parts, wire_size: wire.sum(&:bytesize))}")
78
+ # Mirrors BaseRunner#kill_on_protocol_error: CLI-level policy
79
+ # that protocol-level disconnects kill the socket so the
80
+ # recv loop unblocks with SocketDeadError.
81
+ def kill_on_protocol_error(event)
82
+ return unless event.type == :disconnected
83
+ error = event.detail && event.detail[:error]
84
+ return unless error.is_a?(Protocol::ZMTP::Error)
85
+ @sock.engine.signal_fatal_error(error)
80
86
  end
81
87
 
82
88
 
@@ -96,7 +102,7 @@ module OMQ
96
102
  def compile_expr
97
103
  @begin_proc, @end_proc, @eval_proc =
98
104
  OMQ::CLI::ExpressionEvaluator.compile_inside_ractor(@config.recv_expr)
99
- @fmt = OMQ::CLI::Formatter.new(@config.format, compress: @config.compress)
105
+ @fmt = OMQ::CLI::Formatter.new(@config.format)
100
106
  @ctx = Object.new
101
107
  @ctx.instance_exec(&@begin_proc) if @begin_proc
102
108
  end
@@ -119,10 +125,8 @@ module OMQ
119
125
  n = @config.count
120
126
  i = 0
121
127
  loop do
122
- wire = @sock.receive
123
- break if wire.nil?
124
- parts = @fmt.decompress(wire)
125
- trace_in(parts, wire)
128
+ parts = @sock.receive
129
+ break if parts.nil?
126
130
  if @eval_proc
127
131
  parts = normalize(
128
132
  @ctx.instance_exec(parts, &@eval_proc)
@@ -145,15 +149,11 @@ module OMQ
145
149
  n = @config.count
146
150
  i = 0
147
151
  loop do
148
- wire = @sock.receive
149
- break if wire.nil?
150
- parts = @fmt.decompress(wire)
151
- trace_in(parts, wire)
152
+ parts = @sock.receive
153
+ break if parts.nil?
152
154
  reply = compute_reply(parts)
153
155
  output(reply)
154
- reply_wire = @fmt.compress(reply)
155
- trace_out(reply, reply_wire)
156
- @sock.send(reply_wire)
156
+ @sock.send(reply)
157
157
  i += 1
158
158
  break if n && n > 0 && i >= n
159
159
  end
data/lib/omq/cli/pipe.rb CHANGED
@@ -11,9 +11,8 @@ module OMQ
11
11
 
12
12
  # @param config [Config] frozen CLI configuration
13
13
  def initialize(config)
14
- @config = config
15
- @fmt_in = Formatter.new(config.format, compress: config.compress_in || config.compress)
16
- @fmt_out = Formatter.new(config.format, compress: config.compress_out || config.compress)
14
+ @config = config
15
+ @fmt = Formatter.new(config.format)
17
16
  end
18
17
 
19
18
 
@@ -51,12 +50,16 @@ module OMQ
51
50
  @pull, @push = build_pull_push(in_eps, out_eps)
52
51
  compile_expr
53
52
  @sock = @pull # for eval instance_exec
54
- start_event_monitors if config.verbose >= 2
53
+ start_event_monitors
55
54
  wait_for_peers_with_timeout if config.timeout
56
55
  setup_sequential_transient(task)
57
56
  @sock.instance_exec(&@recv_begin_proc) if @recv_begin_proc
58
57
  sequential_message_loop(fan_out: out_eps.size > 1)
59
58
  @sock.instance_exec(&@recv_end_proc) if @recv_end_proc
59
+ rescue OMQ::SocketDeadError => error
60
+ reason = error.cause&.message || error.message
61
+ $stderr.write("omq: #{reason}\n")
62
+ exit 1
60
63
  ensure
61
64
  @pull&.close
62
65
  @push&.close
@@ -85,6 +88,9 @@ module OMQ
85
88
  push = OMQ::PUSH.new(**kwargs)
86
89
  SocketSetup.apply_options(pull, config)
87
90
  SocketSetup.apply_options(push, config)
91
+ SocketSetup.apply_recv_maxsz(pull, config)
92
+ SocketSetup.apply_compression(pull, config, "pull")
93
+ SocketSetup.apply_compression(push, config, "push")
88
94
  SocketSetup.attach_endpoints(pull, in_eps, verbose: config.verbose, timestamps: config.timestamps)
89
95
  SocketSetup.attach_endpoints(push, out_eps, verbose: config.verbose, timestamps: config.timestamps)
90
96
  [pull, push]
@@ -107,10 +113,9 @@ module OMQ
107
113
  loop do
108
114
  parts = @pull.receive
109
115
  break if parts.nil?
110
- parts = @fmt_in.decompress(parts)
111
116
  parts = eval_recv_expr(parts)
112
117
  if parts && !parts.empty?
113
- @push.send(@fmt_out.compress(parts))
118
+ @push.send(parts)
114
119
  end
115
120
  # Yield after send so send-pump fibers can drain the queue
116
121
  # before the next message is enqueued. Without this, one pump
@@ -163,7 +168,7 @@ module OMQ
163
168
  def set_pipe_process_title
164
169
  in_eps, out_eps = resolve_endpoints
165
170
  title = ["omq pipe"]
166
- title << "-z" if config.compress || config.compress_in || config.compress_out
171
+ title << "-z" if config.compress
167
172
  title << "-P#{config.parallel}" if config.parallel
168
173
  title.concat(in_eps.map(&:url))
169
174
  title << "->"
@@ -193,13 +198,23 @@ module OMQ
193
198
 
194
199
 
195
200
  def start_event_monitors
196
- trace = config.verbose >= 3
201
+ trace = config.verbose >= 3
202
+ log_events = config.verbose >= 2
197
203
  [@pull, @push].each do |sock|
198
204
  sock.monitor(verbose: trace) do |event|
199
- Term.write_event(event, config.timestamps)
205
+ Term.write_event(event, config.timestamps) if log_events
206
+ kill_on_protocol_error(sock, event)
200
207
  end
201
208
  end
202
209
  end
210
+
211
+
212
+ def kill_on_protocol_error(sock, event)
213
+ return unless event.type == :disconnected
214
+ error = event.detail && event.detail[:error]
215
+ return unless error.is_a?(Protocol::ZMTP::Error)
216
+ sock.engine.signal_fatal_error(error)
217
+ end
203
218
  end
204
219
  end
205
220
  end
@@ -19,13 +19,14 @@ module OMQ
19
19
  Async do
20
20
  setup_sockets
21
21
  log_endpoints if @config.verbose >= 1
22
- start_monitors if @config.verbose >= 2
22
+ start_monitors
23
23
  wait_for_peers_with_timeout if @config.timeout
24
24
  compile_expr
25
25
  run_message_loop
26
26
  run_end_block
27
- rescue OMQ::CLI::DecompressError => e
28
- @error_port&.send(e.message)
27
+ rescue OMQ::SocketDeadError => error
28
+ reason = error.cause&.message || error.message
29
+ @log_port.send("omq: #{reason}")
29
30
  ensure
30
31
  @pull&.close
31
32
  @push&.close
@@ -42,6 +43,9 @@ module OMQ
42
43
  @push = OMQ::PUSH.new(**kwargs)
43
44
  OMQ::CLI::SocketSetup.apply_options(@pull, @config)
44
45
  OMQ::CLI::SocketSetup.apply_options(@push, @config)
46
+ OMQ::CLI::SocketSetup.apply_recv_maxsz(@pull, @config)
47
+ OMQ::CLI::SocketSetup.apply_compression(@pull, @config, "pull")
48
+ OMQ::CLI::SocketSetup.apply_compression(@push, @config, "push")
45
49
  OMQ::CLI::SocketSetup.attach_endpoints(@pull, @in_eps, verbose: 0)
46
50
  OMQ::CLI::SocketSetup.attach_endpoints(@push, @out_eps, verbose: 0)
47
51
  end
@@ -55,28 +59,22 @@ module OMQ
55
59
 
56
60
 
57
61
  def start_monitors
58
- # When compress is on, messages are traced explicitly in
59
- # run_message_loop post-decompression; keep engine-level
60
- # traces off to avoid showing compressed bytes.
61
- compress = @config.compress || @config.compress_in || @config.compress_out
62
- trace = @config.verbose >= 3 && !compress
62
+ trace = @config.verbose >= 3
63
+ log_events = @config.verbose >= 2
63
64
  [@pull, @push].each do |sock|
64
65
  sock.monitor(verbose: trace) do |event|
65
- @log_port.send(OMQ::CLI::Term.format_event(event, @config.timestamps))
66
+ @log_port.send(OMQ::CLI::Term.format_event(event, @config.timestamps)) if log_events
67
+ kill_on_protocol_error(sock, event)
66
68
  end
67
69
  end
68
70
  end
69
71
 
70
72
 
71
- def trace_in(parts, wire)
72
- return unless @config.verbose >= 3 && (@config.compress || @config.compress_in)
73
- @log_port.send("#{OMQ::CLI::Term.log_prefix(@config.timestamps)}omq: << #{OMQ::CLI::Formatter.preview(parts, wire_size: wire.sum(&:bytesize))}")
74
- end
75
-
76
-
77
- def trace_out(parts, wire)
78
- return unless @config.verbose >= 3 && (@config.compress || @config.compress_out)
79
- @log_port.send("#{OMQ::CLI::Term.log_prefix(@config.timestamps)}omq: >> #{OMQ::CLI::Formatter.preview(parts, wire_size: wire.sum(&:bytesize))}")
73
+ def kill_on_protocol_error(sock, event)
74
+ return unless event.type == :disconnected
75
+ error = event.detail && event.detail[:error]
76
+ return unless error.is_a?(Protocol::ZMTP::Error)
77
+ sock.engine.signal_fatal_error(error)
80
78
  end
81
79
 
82
80
 
@@ -96,8 +94,7 @@ module OMQ
96
94
  def compile_expr
97
95
  @begin_proc, @end_proc, @eval_proc =
98
96
  OMQ::CLI::ExpressionEvaluator.compile_inside_ractor(@config.recv_expr)
99
- @fmt_in = OMQ::CLI::Formatter.new(@config.format, compress: @config.compress_in || @config.compress)
100
- @fmt_out = OMQ::CLI::Formatter.new(@config.format, compress: @config.compress_out || @config.compress)
97
+ @fmt = OMQ::CLI::Formatter.new(@config.format)
101
98
  @ctx = Object.new
102
99
  @ctx.instance_exec(&@begin_proc) if @begin_proc
103
100
  end
@@ -124,29 +121,21 @@ module OMQ
124
121
 
125
122
 
126
123
  def process_one_eval
127
- wire_in = @pull.receive
128
- return false if wire_in.nil?
129
- parts_in = @fmt_in.decompress(wire_in)
130
- trace_in(parts_in, wire_in)
124
+ parts_in = @pull.receive
125
+ return false if parts_in.nil?
131
126
  parts_out = OMQ::CLI::ExpressionEvaluator.normalize_result(
132
127
  @ctx.instance_exec(parts_in, &@eval_proc), format: @config.format
133
128
  )
134
129
  return true if parts_out.nil? || parts_out.empty?
135
- wire_out = @fmt_out.compress(parts_out)
136
- trace_out(parts_out, wire_out)
137
- @push << wire_out
130
+ @push << parts_out
138
131
  true
139
132
  end
140
133
 
141
134
 
142
135
  def process_one_passthrough
143
- wire_in = @pull.receive
144
- return false if wire_in.nil?
145
- parts_in = @fmt_in.decompress(wire_in)
146
- trace_in(parts_in, wire_in)
147
- wire_out = @fmt_out.compress(parts_in)
148
- trace_out(parts_in, wire_out)
149
- @push << wire_out
136
+ parts_in = @pull.receive
137
+ return false if parts_in.nil?
138
+ @push << parts_in
150
139
  true
151
140
  end
152
141
 
@@ -156,7 +145,7 @@ module OMQ
156
145
  out = OMQ::CLI::ExpressionEvaluator.normalize_result(
157
146
  @ctx.instance_exec(&@end_proc), format: @config.format
158
147
  )
159
- @push << @fmt_out.compress(out) if out && !out.empty?
148
+ @push << out if out && !out.empty?
160
149
  end
161
150
  end
162
151
  end
@@ -13,7 +13,6 @@ module OMQ
13
13
  def send_msg(parts)
14
14
  return if parts.empty?
15
15
  parts = [Marshal.dump(parts)] if config.format == :marshal
16
- parts = @fmt.compress(parts)
17
16
  group = config.group || parts.shift
18
17
  @sock.publish(group, parts.first || "")
19
18
  transient_ready!
@@ -25,7 +25,6 @@ module OMQ
25
25
  break if parts.nil?
26
26
  identity = parts.shift
27
27
  parts.shift if parts.first == ""
28
- parts = @fmt.decompress(parts)
29
28
  result = eval_recv_expr([display_routing_id(identity), *parts])
30
29
  output(result)
31
30
  i += 1
@@ -36,7 +35,7 @@ module OMQ
36
35
 
37
36
 
38
37
  def send_to_peer(id, parts)
39
- @sock.send([id, "", *@fmt.compress(parts)])
38
+ @sock.send([id, "", *parts])
40
39
  end
41
40
  end
42
41
  end
@@ -22,6 +22,40 @@ module OMQ
22
22
  # it entirely with --recv-maxsz 0.
23
23
  DEFAULT_RECV_MAXSZ = 1 << 20
24
24
 
25
+ # Socket types that never receive application data. These opt
26
+ # out of the default passive-compression behavior -- they would
27
+ # otherwise advertise a profile and wrap every outgoing frame in
28
+ # a 4-byte uncompressed sentinel for no benefit, since there's
29
+ # nothing to decompress in return.
30
+ PURE_SEND_TYPES = %w[push pub scatter radio].freeze
31
+
32
+
33
+ # Installs ZMTP-Zstd compression on +sock+ based on +type_name+
34
+ # and the explicit flags in +config+. Three outcomes:
35
+ #
36
+ # * +config.compress+ is true --> active compression (auto-dict).
37
+ # Outgoing frames are compressed; incoming are decoded.
38
+ # * +config.compress+ is false and +type_name+ can receive -->
39
+ # passive compression (RFC Sec. 6.4). The socket advertises the
40
+ # profile so an active peer can compress on the wire and we can
41
+ # decode it, but we never compress our own outgoing frames.
42
+ # * +config.compress+ is false and +type_name+ is in
43
+ # +PURE_SEND_TYPES+ --> no compression. Pure senders have no
44
+ # incoming traffic to decompress, so passive mode is pure
45
+ # overhead on outgoing.
46
+ #
47
+ # Callers pass +type_name+ explicitly (rather than reading it off
48
+ # +config+) so the pipe runners can install different modes on
49
+ # their push/pull ends of a single pipe.
50
+ def self.apply_compression(sock, config, type_name)
51
+ if config.compress
52
+ sock.compression = OMQ::RFC::Zstd::Compression.auto(level: config.compress_level || -3)
53
+ elsif !PURE_SEND_TYPES.include?(type_name)
54
+ sock.compression = OMQ::RFC::Zstd::Compression.auto(passive: true)
55
+ end
56
+ end
57
+
58
+
25
59
  # Apply common socket options from +config+ to +sock+.
26
60
  #
27
61
  def self.apply_options(sock, config)
@@ -44,16 +78,22 @@ module OMQ
44
78
  sock = config.ffi ? klass.new(backend: :ffi) : klass.new
45
79
  sock.conflate = true if config.conflate && %w[pub radio].include?(config.type_name)
46
80
  apply_options(sock, config)
47
- # --recv-maxsz: nil → 1 MiB default; 0 → explicitly unlimited; else → as-is.
81
+ apply_recv_maxsz(sock, config)
82
+ sock.identity = config.identity if config.identity
83
+ sock.router_mandatory = true if config.type_name == "router"
84
+ apply_compression(sock, config, config.type_name)
85
+ sock
86
+ end
87
+
88
+
89
+ # --recv-maxsz: nil → 1 MiB default; 0 → explicitly unlimited; else → as-is.
90
+ def self.apply_recv_maxsz(sock, config)
48
91
  sock.max_message_size =
49
92
  case config.recv_maxsz
50
93
  when nil then DEFAULT_RECV_MAXSZ
51
94
  when 0 then nil
52
95
  else config.recv_maxsz
53
96
  end
54
- sock.identity = config.identity if config.identity
55
- sock.router_mandatory = true if config.type_name == "router"
56
- sock
57
97
  end
58
98
 
59
99
 
data/lib/omq/cli/term.rb CHANGED
@@ -40,12 +40,23 @@ module OMQ
40
40
  prefix = log_prefix(timestamps)
41
41
  case event.type
42
42
  when :message_sent
43
- "#{prefix}omq: >> #{Formatter.preview(event.detail[:parts])}"
43
+ "#{prefix}omq: >> #{Formatter.preview(event.detail[:parts], wire_size: event.detail[:wire_size])}"
44
44
  when :message_received
45
- "#{prefix}omq: << #{Formatter.preview(event.detail[:parts])}"
45
+ "#{prefix}omq: << #{Formatter.preview(event.detail[:parts], wire_size: event.detail[:wire_size])}"
46
+ when :zdict_sent
47
+ "#{prefix}omq: >> ZDICT (#{event.detail[:size]}B)"
48
+ when :zdict_received
49
+ "#{prefix}omq: << ZDICT (#{event.detail[:size]}B)"
46
50
  else
47
51
  ep = event.endpoint ? " #{event.endpoint}" : ""
48
- detail = event.detail ? " #{event.detail}" : ""
52
+ detail =
53
+ if event.detail.is_a?(Hash) && event.detail[:reason]
54
+ " (#{event.detail[:reason]})"
55
+ elsif event.detail
56
+ " #{event.detail}"
57
+ else
58
+ ""
59
+ end
49
60
  "#{prefix}omq: #{event.type}#{ep}#{detail}"
50
61
  end
51
62
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OMQ
4
4
  module CLI
5
- VERSION = "0.13.0"
5
+ VERSION = "0.14.0"
6
6
  end
7
7
  end
data/lib/omq/cli.rb CHANGED
@@ -197,6 +197,7 @@ module OMQ
197
197
  require "omq/rfc/scattergather"
198
198
  require "omq/rfc/channel"
199
199
  require "omq/rfc/p2p"
200
+ require "omq/rfc/zstd"
200
201
  require "async"
201
202
  require "json"
202
203
  require "console"
@@ -247,9 +248,6 @@ module OMQ
247
248
  runner_class.new(config)
248
249
  end
249
250
  runner.call(task)
250
- rescue DecompressError => e
251
- $stderr.puts "omq: #{e.message}"
252
- exit 1
253
251
  rescue IO::TimeoutError, Async::TimeoutError
254
252
  $stderr.puts "omq: timeout" unless config.quiet
255
253
  exit 2
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omq-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -15,14 +15,20 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.18'
18
+ version: '0.19'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 0.19.2
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
26
  - - "~>"
24
27
  - !ruby/object:Gem::Version
25
- version: '0.18'
28
+ version: '0.19'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 0.19.2
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: omq-ffi
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -108,51 +114,51 @@ dependencies:
108
114
  - !ruby/object:Gem::Version
109
115
  version: '0.1'
110
116
  - !ruby/object:Gem::Dependency
111
- name: msgpack
117
+ name: omq-rfc-zstd
112
118
  requirement: !ruby/object:Gem::Requirement
113
119
  requirements:
114
- - - ">="
120
+ - - "~>"
115
121
  - !ruby/object:Gem::Version
116
- version: '0'
122
+ version: '0.1'
117
123
  type: :runtime
118
124
  prerelease: false
119
125
  version_requirements: !ruby/object:Gem::Requirement
120
126
  requirements:
121
- - - ">="
127
+ - - "~>"
122
128
  - !ruby/object:Gem::Version
123
- version: '0'
129
+ version: '0.1'
124
130
  - !ruby/object:Gem::Dependency
125
- name: rbnacl
131
+ name: msgpack
126
132
  requirement: !ruby/object:Gem::Requirement
127
133
  requirements:
128
- - - "~>"
134
+ - - ">="
129
135
  - !ruby/object:Gem::Version
130
- version: '7.0'
136
+ version: '0'
131
137
  type: :runtime
132
138
  prerelease: false
133
139
  version_requirements: !ruby/object:Gem::Requirement
134
140
  requirements:
135
- - - "~>"
141
+ - - ">="
136
142
  - !ruby/object:Gem::Version
137
- version: '7.0'
143
+ version: '0'
138
144
  - !ruby/object:Gem::Dependency
139
- name: rlz4
145
+ name: rbnacl
140
146
  requirement: !ruby/object:Gem::Requirement
141
147
  requirements:
142
148
  - - "~>"
143
149
  - !ruby/object:Gem::Version
144
- version: '0.1'
150
+ version: '7.0'
145
151
  type: :runtime
146
152
  prerelease: false
147
153
  version_requirements: !ruby/object:Gem::Requirement
148
154
  requirements:
149
155
  - - "~>"
150
156
  - !ruby/object:Gem::Version
151
- version: '0.1'
157
+ version: '7.0'
152
158
  description: Command-line tool for sending and receiving ZeroMQ messages on any socket
153
159
  type (REQ/REP, PUB/SUB, PUSH/PULL, DEALER/ROUTER, and all draft types). Supports
154
160
  Ruby eval (-e/-E), script handlers (-r), pipe virtual socket with Ractor parallelism,
155
- multiple formats (ASCII, JSON Lines, msgpack, Marshal), LZ4 compression, and CURVE
161
+ multiple formats (ASCII, JSON Lines, msgpack, Marshal), Zstd compression, and CURVE
156
162
  encryption. Like nngcat from libnng, but with Ruby superpowers.
157
163
  email:
158
164
  - paddor@gmail.com