omq-cli 0.14.0 → 0.14.1

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: b5a558fd06799fcf4bee5d5036a4cac86c8482554df3998e5f42171d04e520db
4
- data.tar.gz: e53bb5236bb5bc660e04ad00c10aa0f3b1e4a848ed9d74363c3943f877bf210f
3
+ metadata.gz: 5352d1d8bdbab1b64aa1e7a36afcc1c70619eb0ae8373d78e95c77669d6a4fec
4
+ data.tar.gz: 38172d3bbff810e3be322e3b8aa4e6d9e09520976d76b34e6721606dd8a15c95
5
5
  SHA512:
6
- metadata.gz: 5841f42d980388be4ce4d0feea88b76a358e86f6ba6e64f6b1775dfdcad16dc349ba3866c824fa74ffb88005876e64c027de9fd93fa21417c8a5a53be3180861
7
- data.tar.gz: 4b68d2c56882c87a06e75fa348eff4f9df75d5cd0c3a85c26e20e6676780c61c0413e329dff7bdbb082b861795cdf09bb30aa4b61c7279c88bbd2e29373d1552
6
+ metadata.gz: 158eb775b0ccea84275a69dc39e8279f41fab5f9bfeb94e8ca0c9e5dd7342b6472980e925850b7d82eceb509981942c42cd80abcb52a9e871b335075898a05b6
7
+ data.tar.gz: 8885731e2d99e87038bb6cffe9f4dc7edd480d285d4b2ac5b5f2244772433f70123e1ed49e9da7a9aa55944aab938bbbf10d7acc98975a04df6b45fd0a1d6b7c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.1 — 2026-04-13
4
+
5
+ ### Changed
6
+
7
+ - **`-M` (Marshal) now carries raw Ruby objects, not array-wrapped
8
+ frames.** Under `-M`, each wire frame is one Marshal-dumped Ruby
9
+ object; inside `-e` / `-E`, `it` is that object directly (not
10
+ `[object]`). Enables natural scalar/hash/custom-class flows:
11
+
12
+ ```sh
13
+ omq push -b tcp://:5557 -ME '"foo"'
14
+ omq pull -c tcp://:5557 -M -e '{it => it.encoding}'
15
+ # => {"foo" => #<Encoding:UTF-8>}
16
+ ```
17
+
18
+ The previous one-element-Array wrap was cosmetic — it always
19
+ produced exactly one wire frame anyway — so no multipart
20
+ semantics are lost.
21
+
22
+ ### Fixed
23
+
24
+ - **`-vvv` trace lines now precede stdout side-effects from
25
+ `-e` / `-E`.** A `<<` / `>>` line is emitted from the app fiber
26
+ *before* the eval expression runs, so sequences like
27
+ `-e 'p it'` read strictly as `trace → eval output → body` on a
28
+ shared tty. Previous design emitted traces from the monitor
29
+ fiber and raced with stdout.
30
+ - **`-vvv` under `-M` now shows the app-level object, not wire
31
+ bytes.** Preview header switches to `(marshal) <inspect>` with
32
+ sanitization and 60-byte truncation, e.g.
33
+ `<< (marshal) [nil, :foo, "bar"]`.
34
+ - **`-vvv` trace preview sanitizes control characters.** Tabs,
35
+ newlines, CR, and backslash render as `\t`, `\n`, `\r`, `\\`;
36
+ other non-printables collapse to `.`. Previously raw LF inside
37
+ a binary frame could leak and break the single-line guarantee.
38
+ - Test suite runs cleanly without protocol-error stderr noise.
39
+
3
40
  ## 0.14.0 — 2026-04-13
4
41
 
5
42
  ### Added
data/README.md CHANGED
@@ -312,7 +312,7 @@ OMQ.outgoing { |msg| [*msg, Time.now.iso8601] }
312
312
  | `--raw` | Raw ZMTP binary (pipe to `hexdump -C` for debugging) |
313
313
  | `-J` / `--jsonl` | JSON Lines — `["frame1","frame2"]` per line |
314
314
  | `--msgpack` | MessagePack arrays (binary stream) |
315
- | `-M` / `--marshal` | Ruby Marshal (binary stream of `Array<String>` objects) |
315
+ | `-M` / `--marshal` | Ruby Marshal one arbitrary Ruby object per message |
316
316
 
317
317
  Multipart messages: in ASCII/quoted mode, frames are tab-separated. In JSONL mode,
318
318
  each message is a JSON array.
@@ -326,6 +326,20 @@ echo '["key","value"]' | omq push -c tcp://localhost:5557 -J
326
326
  omq pull -b tcp://:5557 -J
327
327
  ```
328
328
 
329
+ Under `-M`, each wire frame is one Marshal-dumped Ruby object. Inside `-e` / `-E`,
330
+ `it` is that raw object — not an Array of frames — so scalars, hashes, custom
331
+ classes, or any Marshal-safe value flow through transparently:
332
+
333
+ ```sh
334
+ # send a bare String, receive a { string => encoding } Hash
335
+ omq push -b tcp://:5557 -ME '"foo"'
336
+ omq pull -c tcp://:5557 -M -e '{it => it.encoding}'
337
+ # => {"foo" => #<Encoding:UTF-8>}
338
+ ```
339
+
340
+ At `-vvv`, trace lines for `-M` render the app-level object instead of wire
341
+ bytes: `omq: >> (marshal) [nil, :foo, "bar"]`.
342
+
329
343
  ## Timing
330
344
 
331
345
  | Flag | Effect |
@@ -266,6 +266,7 @@ module OMQ
266
266
  loop do
267
267
  parts = recv_msg
268
268
  break if parts.nil?
269
+ trace_recv(parts)
269
270
  parts = eval_recv_expr(parts)
270
271
  output(parts)
271
272
  i += 1
@@ -292,12 +293,24 @@ module OMQ
292
293
  @recv_tick_eof = true
293
294
  return 0
294
295
  end
296
+ trace_recv(parts)
295
297
  parts = eval_recv_expr(parts)
296
298
  output(parts)
297
299
  1
298
300
  end
299
301
 
300
302
 
303
+ # At -vvv, log the received message *before* eval runs. Eval
304
+ # may write to stdout (e.g. `-e 'p it'`), and we want the
305
+ # trace line to precede any such output so the sequence on the
306
+ # terminal reads as: trace → eval side-effects → body.
307
+ def trace_recv(parts)
308
+ return unless config.verbose >= 3
309
+ $stderr.write("#{Term.log_prefix(config.timestamps)}omq: << #{Formatter.preview(parts, format: config.format)}\n")
310
+ $stderr.flush
311
+ end
312
+
313
+
301
314
  def wait_for_loops(receiver, sender)
302
315
  if config.data || config.file || config.send_expr || config.recv_expr || config.target
303
316
  sender.wait
@@ -316,13 +329,29 @@ module OMQ
316
329
 
317
330
 
318
331
  def send_msg(parts)
319
- return if parts.empty?
320
- parts = [Marshal.dump(parts)] if config.format == :marshal
321
- @sock.send(parts)
332
+ case config.format
333
+ when :marshal
334
+ trace_send(parts)
335
+ @sock.send([Marshal.dump(parts)])
336
+ else
337
+ return if parts.empty?
338
+ trace_send(parts)
339
+ @sock.send(parts)
340
+ end
322
341
  transient_ready!
323
342
  end
324
343
 
325
344
 
345
+ # Symmetric to #trace_recv — log the outgoing message *before*
346
+ # Marshal.dump runs, so -M traces show the app-level object
347
+ # (`[nil, :foo, "bar"]`) instead of the wire-side dump bytes.
348
+ def trace_send(parts)
349
+ return unless config.verbose >= 3
350
+ $stderr.write("#{Term.log_prefix(config.timestamps)}omq: >> #{Formatter.preview(parts, format: config.format)}\n")
351
+ $stderr.flush
352
+ end
353
+
354
+
326
355
  def recv_msg
327
356
  parts = @sock.receive
328
357
  return nil if parts.nil?
@@ -392,10 +421,6 @@ module OMQ
392
421
 
393
422
  def output(parts)
394
423
  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
399
424
  $stdout.write(@fmt.encode(parts))
400
425
  $stdout.flush
401
426
  end
@@ -473,15 +498,20 @@ module OMQ
473
498
  # -vv log connect/disconnect/retry/timeout events
474
499
  # -vvv also log message sent/received traces
475
500
  # --timestamps[=s|ms|us]: prepend UTC timestamps to log lines
501
+ #
502
+ # :message_received and :message_sent are intentionally skipped
503
+ # here and traced from BaseRunner#trace_recv / #trace_send
504
+ # instead — same fiber as the body write, so trace-then-body
505
+ # ordering is strict on a shared tty. The monitor-fiber path
506
+ # suffered from $stderr/$stdout buffer races and from dumping
507
+ # wire-side bytes (pre-Marshal.load on recv, post-Marshal.dump
508
+ # on send) instead of app-level parts.
509
+ SKIP_MONITOR_EVENTS = %i[message_received message_sent].freeze
476
510
  def start_event_monitor
477
- trace = config.verbose >= 3
478
- log_events = config.verbose >= 2
511
+ trace = config.verbose >= 3
512
+ log_events = config.verbose >= 2
479
513
  @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
514
+ Term.write_event(event, config.timestamps) if log_events && !SKIP_MONITOR_EVENTS.include?(event.type)
485
515
  kill_on_protocol_error(event)
486
516
  end
487
517
  end
@@ -123,6 +123,22 @@ module OMQ
123
123
  # multipart via tabs
124
124
  printf "routing-key\tpayload" | omq push --connect tcp://localhost:5557
125
125
 
126
+ -- Marshal (arbitrary Ruby objects) -------------------------
127
+
128
+ # With -M, each message on the wire is one Marshal-dumped
129
+ # Ruby object. Inside -e/-E, `it` is that raw object — not
130
+ # an Array of frames — so you can send/receive scalars,
131
+ # hashes, custom classes, whatever Marshal handles.
132
+
133
+ # send a bare string, receive a { string => encoding } hash
134
+ omq push -b tcp://:5557 -ME '"foo"'
135
+ omq pull -c tcp://:5557 -Mvvv -e '{it => it.encoding}'
136
+ # output: {"foo" => #<Encoding:UTF-8>}
137
+
138
+ # -vvv traces render the app object, not wire bytes
139
+ omq push -b tcp://:5557 -ME '{now: Time.now, pid: Process.pid}' -vvv
140
+ # >> (marshal) {now: 2026-04-13 ..., pid: 12345}
141
+
126
142
  -- Compression ----------------------------------------------
127
143
 
128
144
  # ZMTP-Zstd is negotiated transparently during the handshake.
@@ -344,7 +360,7 @@ module OMQ
344
360
  o.on( "--raw", "Raw binary, no framing") { opts[:format] = :raw }
345
361
  o.on("-J", "--jsonl", "JSON Lines (array of strings per line)") { opts[:format] = :jsonl }
346
362
  o.on( "--msgpack", "MessagePack arrays (binary stream)") { require "msgpack"; opts[:format] = :msgpack }
347
- o.on("-M", "--marshal", "Ruby Marshal stream (binary, Array<String>)") { opts[:format] = :marshal }
363
+ o.on("-M", "--marshal", "Ruby Marshal stream (one arbitrary object per message)") { opts[:format] = :marshal }
348
364
 
349
365
  o.separator "\nSubscription/groups:"
350
366
  o.on("-s", "--subscribe PREFIX", "Subscribe prefix (SUB, default all)") { |v| opts[:subscribes] << v }
@@ -46,7 +46,7 @@ module OMQ
46
46
  result = context.instance_exec(parts, &@eval_proc)
47
47
  return nil if result.nil?
48
48
  return SENT if result.equal?(context)
49
- return [result] if @format == :marshal
49
+ return result if @format == :marshal
50
50
 
51
51
  result = result.is_a?(Array) ? result : [result]
52
52
  result.map(&:to_s)
@@ -56,14 +56,16 @@ module OMQ
56
56
  end
57
57
 
58
58
 
59
- # Normalises an eval result to nil (skip) or an Array.
59
+ # Normalises an eval result to nil (skip), an Array (text formats),
60
+ # or an arbitrary Ruby object (+:marshal+).
61
+ #
60
62
  # 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).
63
+ # When +format+ is :marshal, the raw result is passed through — the
64
+ # wire path will Marshal.dump it into a single frame.
63
65
  #
64
66
  def self.normalize_result(result, format: nil)
65
67
  return nil if result.nil?
66
- return [result] if format == :marshal
68
+ return result if format == :marshal
67
69
  result = result.is_a?(Array) ? result : [result]
68
70
  result.map(&:to_s)
69
71
  end
@@ -32,7 +32,8 @@ module OMQ
32
32
  when :msgpack
33
33
  MessagePack.pack(parts)
34
34
  when :marshal
35
- parts.map(&:inspect).join("\t") + "\n"
35
+ # Under -M, `parts` is a single Ruby object (not a frame array).
36
+ parts.inspect + "\n"
36
37
  end
37
38
  end
38
39
 
@@ -80,26 +81,74 @@ module OMQ
80
81
  end
81
82
 
82
83
 
84
+ # Whitespace/backslash → visible escape sequence used by
85
+ # {Formatter.sanitize}. Everything else outside printable ASCII
86
+ # collapses to '.' via a single String#tr call.
87
+ LINE_ESCAPES = {
88
+ "\t" => '\\t',
89
+ "\n" => '\\n',
90
+ "\r" => '\\r',
91
+ "\\" => '\\\\',
92
+ }.freeze
93
+
94
+
83
95
  # Formats message parts for human-readable preview (logging).
84
96
  # When +wire_size+ is given (ZMTP-Zstd negotiated), the header
85
97
  # also shows the compressed on-the-wire size: "(29B wire=12B)".
98
+ # Accepts either wire-side Array<String> (monitor events) or
99
+ # post-decode app parts that may contain non-String objects
100
+ # (e.g. -M Marshal.load output).
101
+ #
102
+ # When +format+ is +:marshal+, +parts+ is the raw Ruby object
103
+ # itself (not an Array of frames); the preview inspects it so
104
+ # the reader sees the actual payload structure (e.g.
105
+ # `[nil, :foo, "bar"]`) instead of a meaningless "1obj" header.
86
106
  #
87
- # @param parts [Array<String>] plaintext message frames
107
+ # @param parts [Array<String, Object>, Object] message frames, or raw object when +format+ is :marshal
108
+ # @param format [Symbol, nil] active CLI format (:marshal enables object-inspect mode)
88
109
  # @param wire_size [Integer, nil] compressed bytes on the wire
89
110
  # @return [String] truncated preview of each frame joined by |
90
- def self.preview(parts, wire_size: nil)
91
- total = parts.sum(&:bytesize)
111
+ def self.preview(parts, format: nil, wire_size: nil)
112
+ if format == :marshal
113
+ inspected = parts.inspect
114
+ truncated = inspected.bytesize > 60
115
+ inspected = inspected.byteslice(0, 60) if truncated
116
+ out = +"(marshal) #{sanitize(inspected)}"
117
+ out << "…" if truncated
118
+ return out
119
+ end
120
+
92
121
  nparts = parts.size
93
122
  shown = parts.first(3).map { |p| preview_frame(p) }
94
123
  tail = nparts > 3 ? "|…" : ""
95
- size = wire_size ? "#{total}B wire=#{wire_size}B" : "#{total}B"
124
+ total = parts.all?(String) ? parts.sum(&:bytesize) : nil
125
+ size =
126
+ if wire_size && total
127
+ "#{total}B wire=#{wire_size}B"
128
+ elsif total
129
+ "#{total}B"
130
+ else
131
+ "#{nparts}obj"
132
+ end
96
133
  header = nparts > 1 ? "(#{size} #{nparts}F)" : "(#{size})"
97
134
 
98
135
  "#{header} #{shown.join("|")}#{tail}"
99
136
  end
100
137
 
101
138
 
139
+ # Renders one frame or decoded object for {Formatter.preview}.
140
+ # Strings are sanitized byte-wise (first 12 bytes); non-String
141
+ # objects fall back to #inspect (always single-line) truncated
142
+ # at 24 bytes.
143
+ #
144
+ # @param part [String, Object]
145
+ # @return [String]
102
146
  def self.preview_frame(part)
147
+ unless part.is_a?(String)
148
+ s = part.inspect
149
+ return s.bytesize > 24 ? "#{s.byteslice(0, 24)}…" : s
150
+ end
151
+
103
152
  bytes = part.b
104
153
  # Empty frames must render as a visible marker, not as the empty
105
154
  # string — otherwise joining with "|" would produce misleading
@@ -113,11 +162,24 @@ module OMQ
113
162
  if printable < sample.bytesize / 2
114
163
  "[#{bytes.bytesize}B]"
115
164
  elsif bytes.bytesize > 12
116
- "#{sample.gsub(/[^[:print:]]/, ".")}…"
165
+ "#{sanitize(sample)}…"
117
166
  else
118
- sample.gsub(/[^[:print:]]/, ".")
167
+ sanitize(sample)
119
168
  end
120
169
  end
170
+
171
+
172
+ # Escapes bytes so a preview/body line is guaranteed single-line
173
+ # on a shared tty. Tab/newline/CR/backslash render as literal
174
+ # \t/\n/\r/\\; other non-printables collapse to '.'. Forced to
175
+ # binary encoding first to prevent UTF-8 quirks from rendering
176
+ # raw LF bytes.
177
+ #
178
+ # @param bytes [String]
179
+ # @return [String]
180
+ def self.sanitize(bytes)
181
+ bytes.b.gsub(/[\t\n\r\\]/, LINE_ESCAPES).tr("^ -~", ".")
182
+ end
121
183
  end
122
184
  end
123
185
  end
@@ -11,10 +11,16 @@ module OMQ
11
11
 
12
12
 
13
13
  def send_msg(parts)
14
- return if parts.empty?
15
- parts = [Marshal.dump(parts)] if config.format == :marshal
16
- group = config.group || parts.shift
17
- @sock.publish(group, parts.first || "")
14
+ case config.format
15
+ when :marshal
16
+ trace_send(parts)
17
+ @sock.publish(config.group || "", Marshal.dump(parts))
18
+ else
19
+ return if parts.empty?
20
+ trace_send(parts)
21
+ group = config.group || parts.shift
22
+ @sock.publish(group, parts.first || "")
23
+ end
18
24
  transient_ready!
19
25
  end
20
26
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OMQ
4
4
  module CLI
5
- VERSION = "0.14.0"
5
+ VERSION = "0.14.1"
6
6
  end
7
7
  end
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.14.0
4
+ version: 0.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger