omq-cli 0.7.1 → 0.8.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: a03141c60a8af670aa4de42bc5b5d286fbf3a975c5a455e9276bc2d4b87a0fb8
4
- data.tar.gz: 2755cb1054df95a64b5faebe481e93ac2e3f391e878f54b5a30a17514dde399a
3
+ metadata.gz: e9d13a0ffe0dbde54709fe0489385447cf61913d8a0b7d9abc2caa67e64fd7a5
4
+ data.tar.gz: 6987e6aa0569b9e2bc41d6265adf7ea6bc05458086764df0e3c6ca501f08849d
5
5
  SHA512:
6
- metadata.gz: f245e7f524d745382074347852f1d34e5e1f46e633fe024b7888c05942a399639e69e876ae92c5ace5172800b0dc6a3bd8cd088d3b52f389ea0fb212e0f8da58
7
- data.tar.gz: 34992fe5962fd2639b0c3e92597e3d2d6a134f3ac4fecadfa3d64b1d566994cf9f8ef36ce08fc929ab97bfcf7a7b4dd7f9c1de241b8c87e2a207ce17a838c7d2
6
+ metadata.gz: 71c929c48a622fe318a531b03a6a7ab3f15c418dcf5b7662f000be0865423c660beb5967536d9ff30d26cd601648638cfdaa63f535a2da0e3dcc076c5bb36ed3
7
+ data.tar.gz: a3e937b036c55ae4072ddccb38b9acf278e22f8e60ee957b9441611d2a256f34b02cde3c3c847b53811e7365e498fcdeeab5aa512bf2f22c05fa309ce6c997e0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 — 2026-04-08
4
+
5
+ ### Added
6
+
7
+ - **`-P` for pull, gather, and rep** — parallel Ractor workers for recv-only
8
+ and request-reply socket types. Output serialized through `Ractor::Port`
9
+ to avoid scrambled stdout.
10
+ - **`RactorHelpers` module** — shared Ractor infrastructure: `preresolve_tcp`,
11
+ `start_log_consumer`, `start_output_consumer` with `SHUTDOWN` sentinel for
12
+ clean consumer shutdown.
13
+ - **`ParallelWorker` class** — general Ractor worker for parallel socket modes.
14
+ - **Process titles** — all runners set descriptive `proctitle`
15
+ (`omq TYPE [-z] [-PN] ENDPOINTS`). Pipe shows `omq pipe [-z] [-PN] IN -> OUT`.
16
+ Bare script mode shows `omq script`.
17
+
18
+ ### Changed
19
+
20
+ - **ASCII-only source** — replaced all Unicode special characters (em-dashes,
21
+ box-drawing, arrows) with ASCII equivalents in lib/ and test/.
22
+ - **Pipe default HWM** — pipe sockets now default to HWM of 64 (instead of
23
+ the socket default 1000) to bound memory with large messages in pipeline
24
+ stages. Override with `--send-hwm` / `--recv-hwm`.
25
+ - **Message preview** — total byte count first, 12 chars per part, max 3 parts
26
+ shown (`(1234B) frame1|frame2|frame3|...(5 parts)`).
27
+ - **`SocketSetup.apply_options`** — extracted shared socket option setup,
28
+ used by `BaseRunner`, `PipeRunner`, `PipeWorker`, and `ParallelWorker`.
29
+ - **`Formatter.preview`** — extracted from duplicated `msg_preview` methods.
30
+ - **Pipe/PipeWorker** — use bare `.new` for sockets, `SocketSetup.apply_options`
31
+ for configuration, `RactorHelpers` for Ractor infrastructure.
32
+
33
+ ## 0.7.2 — 2026-04-07
34
+
35
+ ### Changed
36
+
37
+ - **Cleaned up pipe.rb** — removed unused `@fmt` formatters, dead `log` method,
38
+ and `with_timeout` wrapper. Moved `preresolve_tcp` and `start_log_consumer` to
39
+ `PipeWorker` class methods.
40
+ - **Guard `with_timeout(nil)`** — `Fiber.scheduler.with_timeout(nil)` fires
41
+ immediately in Async; peer-wait and pipe sequential mode now skip the timeout
42
+ wrapper when `config.timeout` is nil.
43
+
3
44
  ## 0.7.1 — 2026-04-07
4
45
 
5
46
  ### Fixed
@@ -24,6 +24,7 @@ module OMQ
24
24
  # @param task [Async::Task] the parent async task
25
25
  # @return [void]
26
26
  def call(task)
27
+ set_process_title
27
28
  setup_socket
28
29
  start_event_monitor if config.verbose >= 2
29
30
  maybe_start_transient_monitor(task)
@@ -46,7 +47,33 @@ module OMQ
46
47
  end
47
48
 
48
49
 
49
- # ── Socket creation ─────────────────────────────────────────────
50
+ # -- Parallel Ractor workers -----------------------------------------
51
+
52
+
53
+ def run_parallel_workers(socket_sym)
54
+ OMQ.freeze_for_ractors!
55
+ eps = RactorHelpers.preresolve_tcp(config.endpoints)
56
+ output_port, output_thread = RactorHelpers.start_output_consumer
57
+ log_port, log_thread = RactorHelpers.start_log_consumer
58
+
59
+ workers = config.parallel.times.map do
60
+ ::Ractor.new(config, socket_sym, eps, output_port, log_port) do |cfg, sym, e, oport, lport|
61
+ ParallelWorker.new(cfg, sym, e, oport, lport).call
62
+ end
63
+ end
64
+
65
+ workers.each do |w|
66
+ w.join
67
+ rescue ::Ractor::RemoteError => e
68
+ $stderr.write("omq: Ractor error: #{e.cause&.message || e.message}\n")
69
+ end
70
+ ensure
71
+ RactorHelpers.stop_consumer(output_port, output_thread) if output_port
72
+ RactorHelpers.stop_consumer(log_port, log_thread) if log_port
73
+ end
74
+
75
+
76
+ # -- Socket creation ---------------------------------------------
50
77
 
51
78
 
52
79
  def setup_socket
@@ -68,7 +95,7 @@ module OMQ
68
95
  end
69
96
 
70
97
 
71
- # ── Transient disconnect monitor ────────────────────────────────
98
+ # -- Transient disconnect monitor --------------------------------
72
99
 
73
100
 
74
101
  def maybe_start_transient_monitor(task)
@@ -83,7 +110,7 @@ module OMQ
83
110
  end
84
111
 
85
112
 
86
- # ── BEGIN / END blocks ──────────────────────────────────────────
113
+ # -- BEGIN / END blocks ------------------------------------------
87
114
 
88
115
 
89
116
  def run_begin_blocks
@@ -98,7 +125,7 @@ module OMQ
98
125
  end
99
126
 
100
127
 
101
- # ── Peer wait with grace period ─────────────────────────────────
128
+ # -- Peer wait with grace period ---------------------------------
102
129
 
103
130
 
104
131
  def needs_peer_wait?
@@ -107,12 +134,18 @@ module OMQ
107
134
 
108
135
 
109
136
  def wait_for_peer
110
- with_timeout(config.timeout) do
137
+ wait_body = proc do
111
138
  @sock.peer_connected.wait
112
139
  log "Peer connected"
113
140
  wait_for_subscriber
114
141
  apply_grace_period
115
142
  end
143
+
144
+ if config.timeout
145
+ Fiber.scheduler.with_timeout(config.timeout, &wait_body)
146
+ else
147
+ wait_body.call
148
+ end
116
149
  end
117
150
 
118
151
 
@@ -133,19 +166,7 @@ module OMQ
133
166
  end
134
167
 
135
168
 
136
- # ── Timeout helper ──────────────────────────────────────────────
137
-
138
-
139
- def with_timeout(seconds)
140
- if seconds
141
- Async::Task.current.with_timeout(seconds) { yield }
142
- else
143
- yield
144
- end
145
- end
146
-
147
-
148
- # ── Socket setup ────────────────────────────────────────────────
169
+ # -- Socket setup ------------------------------------------------
149
170
 
150
171
 
151
172
  def setup_subscriptions
@@ -163,7 +184,7 @@ module OMQ
163
184
  end
164
185
 
165
186
 
166
- # ── Shared loop bodies ──────────────────────────────────────────
187
+ # -- Shared loop bodies ------------------------------------------
167
188
 
168
189
 
169
190
  def run_send_logic
@@ -279,7 +300,7 @@ module OMQ
279
300
  end
280
301
 
281
302
 
282
- # ── Message I/O ─────────────────────────────────────────────────
303
+ # -- Message I/O -------------------------------------------------
283
304
 
284
305
 
285
306
  def send_msg(parts)
@@ -366,7 +387,7 @@ module OMQ
366
387
  end
367
388
 
368
389
 
369
- # ── Eval ────────────────────────────────────────────────────────
390
+ # -- Eval --------------------------------------------------------
370
391
 
371
392
 
372
393
  def compile_expr
@@ -383,7 +404,7 @@ module OMQ
383
404
 
384
405
 
385
406
  def assign_send_aliases
386
- # Keep ivar aliases subclasses check these directly
407
+ # Keep ivar aliases -- subclasses check these directly
387
408
  @send_begin_proc = @send_evaluator.begin_proc
388
409
  @send_eval_proc = @send_evaluator.eval_proc
389
410
  @send_end_proc = @send_evaluator.end_proc
@@ -410,7 +431,22 @@ module OMQ
410
431
  SENT = ExpressionEvaluator::SENT
411
432
 
412
433
 
413
- # ── Logging ─────────────────────────────────────────────────────
434
+ # -- Process title -------------------------------------------------
435
+
436
+
437
+ def set_process_title(endpoints: nil)
438
+ eps = endpoints || config.endpoints
439
+ title = ["omq", config.type_name]
440
+ title << "-z" if config.compress
441
+ title << "-P#{config.parallel}" if config.parallel
442
+ eps.each do |ep|
443
+ title << (ep.respond_to?(:url) ? ep.url : ep.to_s)
444
+ end
445
+ Process.setproctitle(title.join(" "))
446
+ end
447
+
448
+
449
+ # -- Logging -----------------------------------------------------
414
450
 
415
451
 
416
452
  def log(msg)
@@ -425,9 +461,9 @@ module OMQ
425
461
  @sock.monitor(verbose: verbose) do |event|
426
462
  case event.type
427
463
  when :message_sent
428
- $stderr.write("omq: >> #{msg_preview(event.detail[:parts])}\n")
464
+ $stderr.write("omq: >> #{Formatter.preview(event.detail[:parts])}\n")
429
465
  when :message_received
430
- $stderr.write("omq: << #{msg_preview(event.detail[:parts])}\n")
466
+ $stderr.write("omq: << #{Formatter.preview(event.detail[:parts])}\n")
431
467
  else
432
468
  ep = event.endpoint ? " #{event.endpoint}" : ""
433
469
  detail = event.detail ? " #{event.detail}" : ""
@@ -435,22 +471,6 @@ module OMQ
435
471
  end
436
472
  end
437
473
  end
438
-
439
-
440
- def msg_preview(parts)
441
- parts.map { |p| preview_bytes(p) }.join(" | ")
442
- end
443
-
444
-
445
- def preview_bytes(str)
446
- bytes = str.b
447
- preview = bytes[0, 10].gsub(/[^[:print:]]/, ".")
448
- if bytes.bytesize > 10
449
- "#{preview}... (#{bytes.bytesize}B)"
450
- else
451
- preview
452
- end
453
- end
454
474
  end
455
475
  end
456
476
  end
@@ -6,12 +6,12 @@ module OMQ
6
6
  #
7
7
  class CliParser
8
8
  EXAMPLES = <<~'TEXT'
9
- ── Request / Reply ──────────────────────────────────────────
9
+ -- Request / Reply ------------------------------------------
10
10
 
11
- ┌─────┐ "hello" ┌─────┐
12
- REQ │────────────→│ REP
13
- │←────────────│
14
- └─────┘ "HELLO" └─────┘
11
+ +-----+ "hello" +-----+
12
+ | REQ |------------->| REP |
13
+ | |<-------------| |
14
+ +-----+ "HELLO" +-----+
15
15
 
16
16
  # terminal 1: echo server
17
17
  omq rep --bind tcp://:5555 --recv-eval '$F.map(&:upcase)'
@@ -23,11 +23,11 @@ module OMQ
23
23
  omq rep --bind ipc:///tmp/echo.sock --echo &
24
24
  echo "hello" | omq req --connect ipc:///tmp/echo.sock
25
25
 
26
- ── Publish / Subscribe ──────────────────────────────────────
26
+ -- Publish / Subscribe --------------------------------------
27
27
 
28
- ┌─────┐ "weather.nyc 72F" ┌─────┐
29
- PUB │────────────────────→│ SUB --subscribe "weather."
30
- └─────┘ └─────┘
28
+ +-----+ "weather.nyc 72F" +-----+
29
+ | PUB |--------------------->| SUB | --subscribe "weather."
30
+ +-----+ +-----+
31
31
 
32
32
  # terminal 1: subscriber (all topics by default)
33
33
  omq sub --bind tcp://:5556
@@ -35,11 +35,11 @@ module OMQ
35
35
  # terminal 2: publisher (needs --delay for subscription to propagate)
36
36
  echo "weather.nyc 72F" | omq pub --connect tcp://localhost:5556 --delay 1
37
37
 
38
- ── Periodic Publish ───────────────────────────────────────────
38
+ -- Periodic Publish -------------------------------------------
39
39
 
40
- ┌─────┐ "tick 1" ┌─────┐
41
- PUB │──(every 1s)─→│ SUB
42
- └─────┘ └─────┘
40
+ +-----+ "tick 1" +-----+
41
+ | PUB |--(every 1s)-->| SUB |
42
+ +-----+ +-----+
43
43
 
44
44
  # terminal 1: subscriber
45
45
  omq sub --bind tcp://:5556
@@ -52,11 +52,11 @@ module OMQ
52
52
  omq pub --connect tcp://localhost:5556 --delay 1 \
53
53
  --data "tick" --interval 1 --count 5
54
54
 
55
- ── Pipeline ─────────────────────────────────────────────────
55
+ -- Pipeline -------------------------------------------------
56
56
 
57
- ┌──────┐ ┌──────┐
58
- PUSH │──────────→│ PULL
59
- └──────┘ └──────┘
57
+ +------+ +------+
58
+ | PUSH |----------->| PULL |
59
+ +------+ +------+
60
60
 
61
61
  # terminal 1: worker
62
62
  omq pull --bind tcp://:5557
@@ -68,16 +68,16 @@ module OMQ
68
68
  omq pull --bind ipc:///tmp/pipeline.sock &
69
69
  echo "task 1" | omq push --connect ipc:///tmp/pipeline.sock
70
70
 
71
- ── Pipe (PULL eval PUSH) ────────────────────────────────
71
+ -- Pipe (PULL -> eval -> PUSH) --------------------------------
72
72
 
73
- ┌──────┐ ┌──────┐ ┌──────┐
74
- PUSH │────────→│ pipe │────────→│ PULL
75
- └──────┘ └──────┘ └──────┘
73
+ +------+ +------+ +------+
74
+ | PUSH |--------->| pipe |--------->| PULL |
75
+ +------+ +------+ +------+
76
76
 
77
77
  # terminal 1: producer
78
78
  echo -e "hello\nworld" | omq push --bind ipc://@work
79
79
 
80
- # terminal 2: worker uppercase each message
80
+ # terminal 2: worker -- uppercase each message
81
81
  omq pipe -c ipc://@work -c ipc://@sink -e '$F.map(&:upcase)'
82
82
  # terminal 3: collector
83
83
  omq pull --bind ipc://@sink
@@ -88,19 +88,19 @@ module OMQ
88
88
  # exit when producer disconnects (--transient)
89
89
  omq pipe -c ipc://@work -c ipc://@sink --transient -e '$F.map(&:upcase)'
90
90
 
91
- # fan-in: multiple sources one sink
91
+ # fan-in: multiple sources -> one sink
92
92
  omq pipe --in -c ipc://@work1 -c ipc://@work2 \
93
93
  --out -c ipc://@sink -e '$F.map(&:upcase)'
94
94
 
95
- # fan-out: one source multiple sinks (round-robin)
95
+ # fan-out: one source -> multiple sinks (round-robin)
96
96
  omq pipe --in -b tcp://:5555 --out -c ipc://@sink1 -c ipc://@sink2 -e '$F'
97
97
 
98
- ── CLIENT / SERVER (draft) ──────────────────────────────────
98
+ -- CLIENT / SERVER (draft) ----------------------------------
99
99
 
100
- ┌────────┐ "hello" ┌────────┐
101
- CLIENT │───────────→│ SERVER --recv-eval '$F.map(&:upcase)'
102
- │←───────────│
103
- └────────┘ "HELLO" └────────┘
100
+ +--------+ "hello" +--------+
101
+ | CLIENT |------------>| SERVER | --recv-eval '$F.map(&:upcase)'
102
+ | |<------------| |
103
+ +--------+ "HELLO" +--------+
104
104
 
105
105
  # terminal 1: upcasing server
106
106
  omq server --bind tcp://:5555 --recv-eval '$F.map(&:upcase)'
@@ -108,28 +108,28 @@ module OMQ
108
108
  # terminal 2: client
109
109
  echo "hello" | omq client --connect tcp://localhost:5555
110
110
 
111
- ── Formats ──────────────────────────────────────────────────
111
+ -- Formats --------------------------------------------------
112
112
 
113
- # ascii (default) non-printable replaced with dots
113
+ # ascii (default) -- non-printable replaced with dots
114
114
  omq pull --bind tcp://:5557 --ascii
115
115
 
116
- # quoted lossless, round-trippable (uses String#dump escaping)
116
+ # quoted -- lossless, round-trippable (uses String#dump escaping)
117
117
  omq pull --bind tcp://:5557 --quoted
118
118
 
119
- # JSON Lines structured, multipart as arrays
119
+ # JSON Lines -- structured, multipart as arrays
120
120
  echo '["key","value"]' | omq push --connect tcp://localhost:5557 --jsonl
121
121
  omq pull --bind tcp://:5557 --jsonl
122
122
 
123
123
  # multipart via tabs
124
124
  printf "routing-key\tpayload" | omq push --connect tcp://localhost:5557
125
125
 
126
- ── Compression ──────────────────────────────────────────────
126
+ -- Compression ----------------------------------------------
127
127
 
128
128
  # both sides must use --compress
129
129
  omq pull --bind tcp://:5557 --compress &
130
130
  echo "compressible data" | omq push --connect tcp://localhost:5557 --compress
131
131
 
132
- ── CURVE Encryption ─────────────────────────────────────────
132
+ -- CURVE Encryption -----------------------------------------
133
133
 
134
134
  # server (prints OMQ_SERVER_KEY=...)
135
135
  omq rep --bind tcp://:5555 --echo --curve-server
@@ -138,12 +138,12 @@ module OMQ
138
138
  echo "secret" | omq req --connect tcp://localhost:5555 \
139
139
  --curve-server-key '<key from server>'
140
140
 
141
- ── ROUTER / DEALER ──────────────────────────────────────────
141
+ -- ROUTER / DEALER ------------------------------------------
142
142
 
143
- ┌────────┐ ┌────────┐
144
- DEALER │─────────→│ ROUTER
145
- id=w1
146
- └────────┘ └────────┘
143
+ +--------+ +--------+
144
+ | DEALER |---------->| ROUTER |
145
+ | id=w1 | | |
146
+ +--------+ +--------+
147
147
 
148
148
  # terminal 1: router shows identity + message
149
149
  omq router --bind tcp://:5555
@@ -151,7 +151,7 @@ module OMQ
151
151
  # terminal 2: dealer with identity
152
152
  echo "hello" | omq dealer --connect tcp://localhost:5555 --identity worker-1
153
153
 
154
- ── Ruby Eval ────────────────────────────────────────────────
154
+ -- Ruby Eval ------------------------------------------------
155
155
 
156
156
  # filter incoming: only pass messages containing "error"
157
157
  omq pull -b tcp://:5557 --recv-eval '$F.first.include?("error") ? $F : nil'
@@ -162,10 +162,10 @@ module OMQ
162
162
  # require a local file, use its methods
163
163
  omq rep --bind tcp://:5555 --require ./transform.rb -e 'upcase_all($F)'
164
164
 
165
- # next skips, break stops regexps match against $_
165
+ # next skips, break stops -- regexps match against $_
166
166
  omq pull -b tcp://:5557 -e 'next if /^#/; break if /quit/; $F'
167
167
 
168
- # BEGIN/END blocks (like awk) accumulate and summarize
168
+ # BEGIN/END blocks (like awk) -- accumulate and summarize
169
169
  omq pull -b tcp://:5557 -e 'BEGIN{@sum = 0} @sum += Integer($_); nil END{puts @sum}'
170
170
 
171
171
  # transform outgoing messages
@@ -174,9 +174,9 @@ module OMQ
174
174
  # REQ: transform request and reply independently
175
175
  echo hello | omq req -c tcp://localhost:5555 -E '$F.map(&:upcase)' -e '$_'
176
176
 
177
- ── Script Handlers (-r) ────────────────────────────────────
177
+ -- Script Handlers (-r) ------------------------------------
178
178
 
179
- # handler.rb register transforms from a file
179
+ # handler.rb -- register transforms from a file
180
180
  # db = PG.connect("dbname=app")
181
181
  # OMQ.incoming { |first_part, _| db.exec(first_part).values.flatten }
182
182
  # at_exit { db.close }
@@ -185,8 +185,8 @@ module OMQ
185
185
  # combine script handlers with inline eval
186
186
  omq req -c tcp://localhost:5555 -r./handler.rb -E '$F.map(&:upcase)'
187
187
 
188
- # OMQ.outgoing { |msg| ... } registered outgoing transform
189
- # OMQ.incoming { |msg| ... } registered incoming transform
188
+ # OMQ.outgoing { |msg| ... } -- registered outgoing transform
189
+ # OMQ.incoming { |msg| ... } -- registered incoming transform
190
190
  # CLI flags (-e/-E) override registered handlers
191
191
  TEXT
192
192
 
@@ -271,7 +271,7 @@ module OMQ
271
271
  o.banner = "Usage: omq TYPE [options]\n\n" \
272
272
  "Types: req, rep, pub, sub, push, pull, pair, dealer, router\n" \
273
273
  "Draft: client, server, radio, dish, scatter, gather, channel, peer\n" \
274
- "Virtual: pipe (PULL eval PUSH)\n\n"
274
+ "Virtual: pipe (PULL -> eval -> PUSH)\n\n"
275
275
 
276
276
  o.separator "Connection:"
277
277
  o.on("-c", "--connect URL", "Connect to endpoint (repeatable)") { |v|
@@ -412,7 +412,7 @@ module OMQ
412
412
  type_name = argv.shift
413
413
  if type_name.nil?
414
414
  abort parser.to_s if opts[:scripts].empty?
415
- # bare script mode type_name stays nil
415
+ # bare script mode -- type_name stays nil
416
416
  elsif !SOCKET_TYPE_NAMES.include?(type_name.downcase)
417
417
  abort "Unknown socket type: #{type_name}. Known: #{SOCKET_TYPE_NAMES.join(', ')}"
418
418
  else
@@ -486,10 +486,15 @@ module OMQ
486
486
  abort "--send-eval and --target are mutually exclusive" if opts[:send_expr] && opts[:target]
487
487
 
488
488
  if opts[:parallel]
489
- abort "-P/--parallel is only valid for pipe" unless type_name == "pipe"
489
+ parallel_types = %w[pipe pull gather rep]
490
+ abort "-P/--parallel is only valid for #{parallel_types.join(", ")}" unless parallel_types.include?(type_name)
490
491
  abort "-P/--parallel must be 1..16" unless (1..16).include?(opts[:parallel])
491
- all_pipe_eps = opts[:in_endpoints] + opts[:out_endpoints] + opts[:endpoints]
492
- abort "-P/--parallel requires all endpoints to use --connect (not --bind)" if all_pipe_eps.any?(&:bind?)
492
+ if type_name == "pipe"
493
+ all_eps = opts[:in_endpoints] + opts[:out_endpoints] + opts[:endpoints]
494
+ else
495
+ all_eps = opts[:endpoints]
496
+ end
497
+ abort "-P/--parallel requires all endpoints to use --connect (not --bind)" if all_eps.any?(&:bind?)
493
498
  end
494
499
 
495
500
  (opts[:connects] + opts[:binds]).each do |url|
@@ -98,6 +98,22 @@ module OMQ
98
98
  rescue
99
99
  abort "omq: decompression failed (did the sender use --compress?)"
100
100
  end
101
+
102
+
103
+ # Formats message parts for human-readable preview (logging).
104
+ #
105
+ # @param parts [Array<String>] message frames
106
+ # @return [String] truncated preview of each frame joined by |
107
+ def self.preview(parts)
108
+ total = parts.sum(&:bytesize)
109
+ shown = parts.first(3).map do |p|
110
+ bytes = p.b
111
+ preview = bytes[0, 12].gsub(/[^[:print:]]/, ".")
112
+ bytes.bytesize > 12 ? "#{preview}..." : preview
113
+ end
114
+ tail = parts.size > 3 ? "|...(#{parts.size} parts)" : ""
115
+ "(#{total}B) #{shown.join("|")}#{tail}"
116
+ end
101
117
  end
102
118
  end
103
119
  end