omq-cli 0.12.3 → 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: bf29033480f27f1a249d6f22b85a2f569687e79ed6ddde06a0ef74eacafa0863
4
- data.tar.gz: 4d51ae13241f047758223a2b4f055721d2b1a8809c5ba7c1154868141b1e22e8
3
+ metadata.gz: b5a558fd06799fcf4bee5d5036a4cac86c8482554df3998e5f42171d04e520db
4
+ data.tar.gz: e53bb5236bb5bc660e04ad00c10aa0f3b1e4a848ed9d74363c3943f877bf210f
5
5
  SHA512:
6
- metadata.gz: 254ca00deef4d9bd9e4bbafd3cd44f785c27f63446437367b178e76711cdeda33ab779f7880e71181d3d60198458bd1f5741f1890e0d9125d797282dbef96189
7
- data.tar.gz: 642c4eebaedc3d3b606693e40c3cf6112ee4c06b60432c60831bd75b0d3be171d81a2f4232c27a49fbed9c287a061e490dc3a62ebdfd618cf0f7fa3ea9624fff
6
+ metadata.gz: 5841f42d980388be4ce4d0feea88b76a358e86f6ba6e64f6b1775dfdcad16dc349ba3866c824fa74ffb88005876e64c027de9fd93fa21417c8a5a53be3180861
7
+ data.tar.gz: 4b68d2c56882c87a06e75fa348eff4f9df75d5cd0c3a85c26e20e6676780c61c0413e329dff7bdbb082b861795cdf09bb30aa4b61c7279c88bbd2e29373d1552
data/CHANGELOG.md CHANGED
@@ -1,5 +1,123 @@
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
+
84
+ ## 0.13.0 — 2026-04-12
85
+
86
+ ### Added
87
+
88
+ - **`--timestamps[=PRECISION]` flag.** Prefix log lines with UTC
89
+ timestamps. Accepts `s`, `ms` (default), or `us`. Replaces the
90
+ former `-vvvv` special meaning, which has been removed.
91
+ - **`-M` / `--marshal` preserves arbitrary objects on the wire.**
92
+ Eval results in `--format marshal` mode (e.g. `Time.now`, hashes,
93
+ UTF-16LE strings) are now passed through unchanged instead of
94
+ being coerced via `#to_s`. Affects both the main runner and
95
+ Ractor workers (`-P`).
96
+ - **`-P0` ⇒ `nproc`.** `-P0` (or bare `-P`) spawns one Ractor worker
97
+ per CPU, capped at 16. Short-option clustering works: `-P0zvv`
98
+ expands to `-P 0 -z -v -v`.
99
+
100
+ ### Changed
101
+
102
+ - **`-vvv` shows decompressed message previews.** When `--compress`
103
+ is active, message traces now log the decompressed parts and
104
+ include the on-wire size: `(5B wire=21B) hello`. Engine-level
105
+ message events are suppressed in compressed mode to avoid
106
+ double-logging compressed bytes.
107
+ - **Truncation marker uses `…` (U+2026).** `Formatter.preview`
108
+ truncation now uses the real horizontal ellipsis character
109
+ instead of three ASCII dots.
110
+
111
+ ### Fixed
112
+
113
+ - **`FrozenError` in eval handlers returning received parts.**
114
+ `ExpressionEvaluator` no longer mutates the result array with
115
+ `#map!`, which crashed when a script handler returned the frozen
116
+ parts array received from the socket.
117
+ - **README:** fixed broken `if /regex/` examples (no implicit `$_`
118
+ match in `instance_exec`), broken `OMQ.incoming`/`OMQ.outgoing`
119
+ handler table, and `-P` examples that no longer parsed.
120
+
3
121
  ## 0.12.3 — 2026-04-10
4
122
 
5
123
  ### Fixed
data/README.md CHANGED
@@ -127,8 +127,8 @@ Pipe creates an internal PULL → eval → PUSH pipeline:
127
127
  ```sh
128
128
  omq pipe -c@work -c@sink -e 'it.map(&:upcase)'
129
129
 
130
- # with Ractor workers for CPU parallelism
131
- omq pipe -c@work -c@sink -P 4 -r./fib.rb -e 'fib(Integer(it.first)).to_s'
130
+ # with Ractor workers for CPU parallelism (-P0 = nproc)
131
+ omq pipe -c@work -c@sink -P0 -r./fib.rb -e 'fib(Integer(it.first)).to_s'
132
132
  ```
133
133
 
134
134
  The first endpoint is the pull-side (input), the second is the push-side (output).
@@ -171,10 +171,10 @@ omq pull -b tcp://:5557 -e '|(key, value)| "#{key}=#{value}"'
171
171
 
172
172
  ```sh
173
173
  # skip messages matching a pattern
174
- omq pull -b tcp://:5557 -e 'next if /^#/; it'
174
+ omq pull -b tcp://:5557 -e 'next if it.first.start_with?("#"); it'
175
175
 
176
176
  # stop on "quit"
177
- omq pull -b tcp://:5557 -e 'break if /quit/; it'
177
+ omq pull -b tcp://:5557 -e 'break if it.first == "quit"; it'
178
178
  ```
179
179
 
180
180
  ### BEGIN/END blocks
@@ -244,8 +244,8 @@ omq req -c tcp://localhost:5555 -r./handler.rb
244
244
 
245
245
  | Method | Effect |
246
246
  |--------|--------|
247
- | `OMQ.outgoing { |msg| ... }` | Register outgoing message transform |
248
- | `OMQ.incoming { |msg| ... }` | Register incoming message transform |
247
+ | `OMQ.outgoing { \|msg\| ... }` | Register outgoing message transform |
248
+ | `OMQ.incoming { \|msg\| ... }` | Register incoming message transform |
249
249
 
250
250
  - use explicit block variable (like `msg`) or `it`
251
251
  - Setup: use local variables and closures at the top of the script
@@ -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
@@ -441,11 +449,11 @@ Pipe creates an in-process PULL → eval → PUSH pipeline:
441
449
  # basic pipe (positional: first = input, second = output)
442
450
  omq pipe -c@work -c@sink -e 'it.map(&:upcase)'
443
451
 
444
- # parallel Ractor workers (default: all CPUs)
445
- omq pipe -c@work -c@sink -P -r./fib.rb -e 'fib(Integer(it.first)).to_s'
452
+ # parallel Ractor workers (-P0 = nproc, also combinable: -P0zvv)
453
+ omq pipe -c@work -c@sink -P0 -r./fib.rb -e 'fib(Integer(it.first)).to_s'
446
454
 
447
455
  # fixed number of workers
448
- omq pipe -c@work -c@sink -P 4 -e 'it.map(&:upcase)'
456
+ omq pipe -c@work -c@sink -P4 -e 'it.map(&:upcase)'
449
457
 
450
458
  # exit when producer disconnects
451
459
  omq pipe -c@work -c@sink --transient -e 'it.map(&:upcase)'
@@ -467,7 +475,7 @@ omq pipe --in -b tcp://:5555 --out -c@sink1 -c@sink2 -e 'it'
467
475
  omq pipe --in -b tcp://:5555 -b tcp://:5556 --out -c tcp://sink:5557 -e 'it'
468
476
 
469
477
  # parallel workers with fan-in (all must be -c)
470
- omq pipe --in -c@a -c@b --out -c@sink -P 4 -e 'it'
478
+ omq pipe --in -c@a -c@b --out -c@sink -P4 -e 'it'
471
479
  ```
472
480
 
473
481
  `-P`/`--parallel` requires all endpoints to be `--connect`. In parallel mode, each Ractor worker
@@ -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
@@ -99,7 +103,7 @@ module OMQ
99
103
 
100
104
 
101
105
  def attach_endpoints
102
- SocketSetup.attach(@sock, config, verbose: config.verbose)
106
+ SocketSetup.attach(@sock, config, verbose: config.verbose, timestamps: config.timestamps)
103
107
  end
104
108
 
105
109
 
@@ -314,16 +318,14 @@ module OMQ
314
318
  def send_msg(parts)
315
319
  return if parts.empty?
316
320
  parts = [Marshal.dump(parts)] if config.format == :marshal
317
- parts = @fmt.compress(parts)
318
321
  @sock.send(parts)
319
322
  transient_ready!
320
323
  end
321
324
 
322
325
 
323
326
  def recv_msg
324
- raw = @sock.receive
325
- return nil if raw.nil?
326
- parts = @fmt.decompress(raw)
327
+ parts = @sock.receive
328
+ return nil if parts.nil?
327
329
  parts = Marshal.load(parts.first) if config.format == :marshal
328
330
  transient_ready!
329
331
  parts
@@ -390,6 +392,10 @@ module OMQ
390
392
 
391
393
  def output(parts)
392
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
393
399
  $stdout.write(@fmt.encode(parts))
394
400
  $stdout.flush
395
401
  end
@@ -462,14 +468,38 @@ module OMQ
462
468
  end
463
469
 
464
470
 
465
- # -vv: log connect/disconnect/retry/timeout events via Socket#monitor
466
- # -vvv: also log message sent/received traces
467
- # -vvvv: also prepend ISO8601 µs-precision timestamps
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
475
+ # --timestamps[=s|ms|us]: prepend UTC timestamps to log lines
468
476
  def start_event_monitor
469
- @sock.monitor(verbose: config.verbose >= 3) do |event|
470
- Term.write_event(event, config.verbose)
477
+ trace = config.verbose >= 3
478
+ log_events = config.verbose >= 2
479
+ @sock.monitor(verbose: trace) do |event|
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)
471
486
  end
472
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
473
503
  end
474
504
  end
475
505
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "socket"
4
+ require "etc"
4
5
 
5
6
  module OMQ
6
7
  module CLI
@@ -124,8 +125,12 @@ module OMQ
124
125
 
125
126
  -- Compression ----------------------------------------------
126
127
 
127
- # both sides must use --compress/-z
128
- 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 &
129
134
  echo "compressible data" | omq push --connect tcp://localhost:5557 -z
130
135
 
131
136
  -- CURVE Encryption -----------------------------------------
@@ -224,13 +229,13 @@ module OMQ
224
229
  rcvbuf: nil,
225
230
  conflate: false,
226
231
  compress: false,
227
- compress_in: false,
228
- compress_out: false,
232
+ compress_level: nil,
229
233
  send_expr: nil,
230
234
  recv_expr: nil,
231
235
  parallel: nil,
232
236
  transient: false,
233
237
  verbose: 0,
238
+ timestamps: nil,
234
239
  quiet: false,
235
240
  echo: false,
236
241
  scripts: [],
@@ -249,6 +254,25 @@ module OMQ
249
254
  end
250
255
 
251
256
 
257
+ # Splits short-option clusters of the form `-P[digits][letters]`
258
+ # so OptionParser sees `-P[digits]` followed by `-[letters]`.
259
+ # Lets `-P0zvv` mean `-P0 -z -v -v` (portable & combinable).
260
+ # Also rewrites bare `--timestamps` to `--timestamps=ms` so
261
+ # OptionParser doesn't consume the next positional token as its
262
+ # argument.
263
+ #
264
+ def split_parallel_cluster(argv)
265
+ argv.flat_map { |a|
266
+ if a =~ /\A-P(\d*)([a-zA-Z].*)\z/
267
+ n, rest = $1, $2
268
+ n.empty? ? ["-P", "-#{rest}"] : ["-P#{n}", "-#{rest}"]
269
+ else
270
+ a
271
+ end
272
+ }.map { |a| a == "--timestamps" ? "--timestamps=ms" : a }
273
+ end
274
+
275
+
252
276
  # Validates option combinations, aborting on bad combos.
253
277
  #
254
278
  def self.validate!(opts)
@@ -365,16 +389,17 @@ module OMQ
365
389
  o.on("--conflate", "Keep only last message per subscriber (PUB/RADIO)") { opts[:conflate] = true }
366
390
 
367
391
  o.separator "\nCompression:"
368
- o.on("-z", "--compress", "LZ4 compression per frame (modal with --in/--out)") do
369
- require "rlz4"
370
- case pipe_side
371
- when :in
372
- opts[:compress_in] = true
373
- when :out
374
- opts[:compress_out] = true
375
- else
376
- opts[:compress] = true
377
- 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
378
403
  end
379
404
 
380
405
  o.separator "\nProcessing (-e = incoming, -E = outgoing):"
@@ -384,8 +409,9 @@ module OMQ
384
409
  require "omq" unless defined?(OMQ::VERSION)
385
410
  opts[:scripts] << (v == "-" ? :stdin : (v.start_with?("./", "../") ? File.expand_path(v) : v))
386
411
  }
387
- o.on("-P", "--parallel N", Integer, "Parallel Ractor workers (max 16)") { |v|
388
- opts[:parallel] = [v, 16].min
412
+ o.on("-P", "--parallel [N]", Integer, "Parallel Ractor workers (0 = nproc, max 16)") { |v|
413
+ n = v.nil? || v.zero? ? Etc.nprocessors : v
414
+ opts[:parallel] = [n, 16].min
389
415
  }
390
416
 
391
417
  o.separator "\nCURVE encryption (requires system libsodium):"
@@ -397,7 +423,10 @@ module OMQ
397
423
  o.separator " OMQ_CRYPTO (backend: rbnacl or nuckle)"
398
424
 
399
425
  o.separator "\nOther:"
400
- o.on("-v", "--verbose", "Verbosity: -v endpoints, -vv events, -vvv messages, -vvvv timestamps") { opts[:verbose] += 1 }
426
+ o.on("-v", "--verbose", "Verbosity: -v endpoints, -vv events, -vvv messages") { opts[:verbose] += 1 }
427
+ o.on( "--timestamps PRECISION", %w[s ms us], "Prefix log lines with UTC timestamp (s/ms/us, default ms)") { |v|
428
+ opts[:timestamps] = v.to_sym
429
+ }
401
430
  o.on("-q", "--quiet", "Suppress message output") { opts[:quiet] = true }
402
431
  o.on( "--transient", "Exit when all peers disconnect") { opts[:transient] = true }
403
432
  o.on( "--ffi", "Use libzmq FFI backend (requires omq-ffi gem + system libzmq 4.x)") do
@@ -427,6 +456,8 @@ module OMQ
427
456
  o.separator "\nExit codes: 0 = success, 1 = error, 2 = timeout"
428
457
  end
429
458
 
459
+ argv = split_parallel_cluster(argv)
460
+
430
461
  begin
431
462
  parser.parse!(argv)
432
463
  rescue OptionParser::ParseError => e
@@ -443,26 +474,9 @@ module OMQ
443
474
  opts[:type_name] = type_name.downcase
444
475
  end
445
476
 
446
- # Normalize shorthand hostnames to concrete addresses.
447
- #
448
- # Binds: tcp://:PORT → loopback (::1 if IPv6 available, else 127.0.0.1)
449
- # tcp://*:PORT → 0.0.0.0 (all interfaces, IPv4)
450
- # tcp://0.0.0.0:PORT, tcp://[::]:PORT → pass through
451
- #
452
- # Connects: tcp://:PORT → localhost (Happy Eyeballs)
453
- # tcp://*:PORT → localhost
454
- #
455
- # The hang on macOS (IPv6 connect(2) not getting ECONNREFUSED via
456
- # kqueue) is fixed by the connect timeout in Engine::Reconnect.
457
- loopback = self.class.loopback_bind_host
458
- normalize_bind = ->(url) { url.sub(%r{\Atcp://\*:}, "tcp://0.0.0.0:").sub(%r{\Atcp://:}, "tcp://#{loopback}:") }
459
- normalize_connect = ->(url) { url.sub(%r{\Atcp://(\*|):}, "tcp://localhost:") }
460
- normalize_ep = ->(ep) { Endpoint.new(ep.bind? ? normalize_bind.call(ep.url) : normalize_connect.call(ep.url), ep.bind?) }
461
- opts[:binds].map!(&normalize_bind)
462
- opts[:connects].map!(&normalize_connect)
463
- opts[:endpoints].map!(&normalize_ep)
464
- opts[:in_endpoints].map!(&normalize_ep)
465
- 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.
466
480
 
467
481
  opts
468
482
  end
@@ -565,19 +579,6 @@ module OMQ
565
579
  end
566
580
 
567
581
 
568
- # Returns the loopback address for bind normalization.
569
- # Prefers IPv6 loopback ([::1]) when the host has at least one
570
- # non-loopback, non-link-local IPv6 address, otherwise 127.0.0.1.
571
- #
572
- def self.loopback_bind_host
573
- @loopback_bind_host ||= begin
574
- has_ipv6 = ::Socket.getifaddrs.any? { |ifa|
575
- addr = ifa.addr
576
- addr&.ipv6? && !addr.ipv6_loopback? && !addr.ipv6_linklocal?
577
- }
578
- has_ipv6 ? "[::1]" : "127.0.0.1"
579
- end
580
- end
581
582
  end
582
583
  end
583
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,13 +44,13 @@ 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,
52
51
  :transient,
53
52
  :verbose,
53
+ :timestamps,
54
54
  :quiet,
55
55
  :echo,
56
56
  :scripts,
@@ -49,20 +49,23 @@ module OMQ
49
49
  return [result] if @format == :marshal
50
50
 
51
51
  result = result.is_a?(Array) ? result : [result]
52
- result.map!(&:to_s)
52
+ result.map(&:to_s)
53
53
  rescue => e
54
54
  $stderr.puts "omq: eval error: #{e.message} (#{e.class})"
55
55
  exit 3
56
56
  end
57
57
 
58
58
 
59
- # Normalises an eval result to nil (skip) or an Array of strings.
59
+ # Normalises an eval result to nil (skip) or an Array.
60
60
  # Used inside Ractor worker blocks where instance methods are unavailable.
61
+ # When +format+ is :marshal, arbitrary objects are preserved (wrapped
62
+ # in a one-element Array so the wire path can Marshal.dump them).
61
63
  #
62
- def self.normalize_result(result)
64
+ def self.normalize_result(result, format: nil)
63
65
  return nil if result.nil?
66
+ return [result] if format == :marshal
64
67
  result = result.is_a?(Array) ? result : [result]
65
- result.map!(&:to_s)
68
+ result.map(&:to_s)
66
69
  end
67
70
 
68
71
 
@@ -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,37 +80,20 @@ 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).
84
+ # When +wire_size+ is given (ZMTP-Zstd negotiated), the header
85
+ # also shows the compressed on-the-wire size: "(29B wire=12B)".
108
86
  #
109
- # @param parts [Array<String>] message frames
87
+ # @param parts [Array<String>] plaintext message frames
88
+ # @param wire_size [Integer, nil] compressed bytes on the wire
110
89
  # @return [String] truncated preview of each frame joined by |
111
- def self.preview(parts)
90
+ def self.preview(parts, wire_size: nil)
112
91
  total = parts.sum(&:bytesize)
113
92
  nparts = parts.size
114
93
  shown = parts.first(3).map { |p| preview_frame(p) }
115
- tail = nparts > 3 ? "|..." : ""
116
- header = nparts > 1 ? "(#{total}B #{nparts}F)" : "(#{total}B)"
94
+ tail = nparts > 3 ? "|…" : ""
95
+ size = wire_size ? "#{total}B wire=#{wire_size}B" : "#{total}B"
96
+ header = nparts > 1 ? "(#{size} #{nparts}F)" : "(#{size})"
117
97
 
118
98
  "#{header} #{shown.join("|")}#{tail}"
119
99
  end
@@ -133,7 +113,7 @@ module OMQ
133
113
  if printable < sample.bytesize / 2
134
114
  "[#{bytes.bytesize}B]"
135
115
  elsif bytes.bytesize > 12
136
- "#{sample.gsub(/[^[:print:]]/, ".")}..."
116
+ "#{sample.gsub(/[^[:print:]]/, ".")}"
137
117
  else
138
118
  sample.gsub(/[^[:print:]]/, ".")
139
119
  end