omq-cli 0.14.0 → 0.14.2

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: 23eb8d602298e5db1259e6d7d0effe08e8e0dfacba55b8d9a5287800290f2eb9
4
+ data.tar.gz: b492d78fd4997c82434853bc284f7f98c662d905a450e3545c1df2ae822bf40c
5
5
  SHA512:
6
- metadata.gz: 5841f42d980388be4ce4d0feea88b76a358e86f6ba6e64f6b1775dfdcad16dc349ba3866c824fa74ffb88005876e64c027de9fd93fa21417c8a5a53be3180861
7
- data.tar.gz: 4b68d2c56882c87a06e75fa348eff4f9df75d5cd0c3a85c26e20e6676780c61c0413e329dff7bdbb082b861795cdf09bb30aa4b61c7279c88bbd2e29373d1552
6
+ metadata.gz: a3abd80d9856c52e2674c86a3e31ed4f02d6511cad2e3d9f35a2ab5b65faaf07acdc4c442f3746bc5ed0dd56e730df0fabc4930c0cdfe05ad8e289335fc0e8d4
7
+ data.tar.gz: 98e5cf25c2e267aace41bb958843459ead62da2a338b8a64aa3f6fc3af571dfefaee30b01a0de720b8880a96c6c0c3b55fe7a8439a6b5151a54085233ded3aaa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.2 — 2026-04-13
4
+
5
+ ### Changed
6
+
7
+ - `kill_on_protocol_error` is now a single
8
+ `SocketSetup.kill_on_protocol_error(sock, event)` class method.
9
+ Previously `BaseRunner`, `ParallelWorker`, and `PipeRunner` each
10
+ carried an identical 4-line copy of the CLI policy that
11
+ protocol-level disconnects mark the socket dead.
12
+ - `ExpressionEvaluator.extract_block` is now a single class method
13
+ used by both the instance compile path and the
14
+ `compile_inside_ractor` path. The in-Ractor copy previously lived
15
+ as a local lambda that duplicated the instance
16
+ `extract_block` method.
17
+ - `Formatter#encode` drops one String allocation per message on
18
+ the ascii / quoted / jsonl / marshal paths by mutating the
19
+ fresh `.join` / `JSON.generate` / `.inspect` result with `<<`
20
+ instead of `+ "\n"`.
21
+ - `Formatter.marshal_preview` and `Formatter.frames_preview` (extracted
22
+ from `Formatter.preview` in the `-vvv` marshal trace work) are now
23
+ `private_class_method` — they were only ever meant to be called
24
+ through `Formatter.preview` but ended up on the public class surface.
25
+ - Dropped a redundant unary `+` before `Formatter.sanitize(...)` in
26
+ `marshal_preview`: `sanitize` already returns a fresh mutable String
27
+ via `.tr`, so the `+""` dup was dead weight.
28
+ - **`-vvv` marshal trace headers now show plaintext and wire byte
29
+ sizes.** Previously `<< (marshal) ...` carried no size info; it
30
+ now renders as `(135B marshal) ...` and, when ZMTP-Zstd
31
+ compression is negotiated, `(135B wire=50B marshal) ...` —
32
+ matching the frame-based preview format used by every other
33
+ `-vvv` output.
34
+ Other formats (ascii/quoted/jsonl/msgpack/raw) already showed
35
+ plaintext size via the frame preview; they now also pick up
36
+ `wire=NB` when compression is active, since `wire_size` is
37
+ side-channelled from `:message_sent` / `:message_received`
38
+ monitor events. Send-side `wire_size` is best-effort — the
39
+ engine's send pump emits the compressed byte count
40
+ asynchronously, so the value reflects the most recently
41
+ *completed* send; receive-side is exact.
42
+ - Hot-path optimized.
43
+
44
+ ## 0.14.1 — 2026-04-13
45
+
46
+ ### Changed
47
+
48
+ - **`-M` (Marshal) now carries raw Ruby objects, not array-wrapped
49
+ frames.** Under `-M`, each wire frame is one Marshal-dumped Ruby
50
+ object; inside `-e` / `-E`, `it` is that object directly (not
51
+ `[object]`). Enables natural scalar/hash/custom-class flows:
52
+
53
+ ```sh
54
+ omq push -b tcp://:5557 -ME '"foo"'
55
+ omq pull -c tcp://:5557 -M -e '{it => it.encoding}'
56
+ # => {"foo" => #<Encoding:UTF-8>}
57
+ ```
58
+
59
+ The previous one-element-Array wrap was cosmetic — it always
60
+ produced exactly one wire frame anyway — so no multipart
61
+ semantics are lost.
62
+
63
+ ### Fixed
64
+
65
+ - **`-vvv` trace lines now precede stdout side-effects from
66
+ `-e` / `-E`.** A `<<` / `>>` line is emitted from the app fiber
67
+ *before* the eval expression runs, so sequences like
68
+ `-e 'p it'` read strictly as `trace → eval output → body` on a
69
+ shared tty. Previous design emitted traces from the monitor
70
+ fiber and raced with stdout.
71
+ - **`-vvv` under `-M` now shows the app-level object, not wire
72
+ bytes.** Preview header switches to `(marshal) <inspect>` with
73
+ sanitization and 60-byte truncation, e.g.
74
+ `<< (marshal) [nil, :foo, "bar"]`.
75
+ - **`-vvv` trace preview sanitizes control characters.** Tabs,
76
+ newlines, CR, and backslash render as `\t`, `\n`, `\r`, `\\`;
77
+ other non-printables collapse to `.`. Previously raw LF inside
78
+ a binary frame could leak and break the single-line guarantee.
79
+ - Test suite runs cleanly without protocol-error stderr noise.
80
+
3
81
  ## 0.14.0 — 2026-04-13
4
82
 
5
83
  ### 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,34 @@ 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
+ #
308
+ # +@last_recv_wire_size+ is populated by the :message_received
309
+ # monitor event, which fires *before* the recv queue enqueue
310
+ # (recv_pump.rb) — so by the time @sock.receive returns here,
311
+ # the cache reflects this message. +@last_recv_uncompressed+
312
+ # is captured in #recv_msg from the raw marshal frame size.
313
+ def trace_recv(parts)
314
+ return unless config.verbose >= 3
315
+ preview = Formatter.preview(parts,
316
+ format: config.format,
317
+ wire_size: @last_recv_wire_size,
318
+ uncompressed_size: @last_recv_uncompressed)
319
+ $stderr.write("#{Term.log_prefix(config.timestamps)}omq: << #{preview}\n")
320
+ $stderr.flush
321
+ end
322
+
323
+
301
324
  def wait_for_loops(receiver, sender)
302
325
  if config.data || config.file || config.send_expr || config.recv_expr || config.target
303
326
  sender.wait
@@ -316,17 +339,49 @@ module OMQ
316
339
 
317
340
 
318
341
  def send_msg(parts)
319
- return if parts.empty?
320
- parts = [Marshal.dump(parts)] if config.format == :marshal
321
- @sock.send(parts)
342
+ case config.format
343
+ when :marshal
344
+ dumped = Marshal.dump(parts)
345
+ trace_send(parts, uncompressed_size: dumped.bytesize)
346
+ @sock.send([dumped])
347
+ else
348
+ return if parts.empty?
349
+ trace_send(parts)
350
+ @sock.send(parts)
351
+ end
322
352
  transient_ready!
323
353
  end
324
354
 
325
355
 
356
+ # Symmetric to #trace_recv — log the outgoing message using the
357
+ # pre-Marshal.dump +parts+, so -M traces show the app-level
358
+ # object (`[nil, :foo, "bar"]`) instead of the wire-side dump
359
+ # bytes. +@last_send_wire_size+ is best-effort: it reflects the
360
+ # *previous* message (populated by the :message_sent monitor
361
+ # event, which fires on a separate fiber after the pump writes),
362
+ # so early sends may show no `wire=` at all. Receive-side tracing
363
+ # is the authoritative path for observing wire bytes.
364
+ def trace_send(parts, uncompressed_size: nil)
365
+ return unless config.verbose >= 3
366
+ preview = Formatter.preview(parts,
367
+ format: config.format,
368
+ wire_size: @last_send_wire_size,
369
+ uncompressed_size: uncompressed_size)
370
+ $stderr.write("#{Term.log_prefix(config.timestamps)}omq: >> #{preview}\n")
371
+ $stderr.flush
372
+ end
373
+
374
+
326
375
  def recv_msg
327
376
  parts = @sock.receive
328
377
  return nil if parts.nil?
329
- parts = Marshal.load(parts.first) if config.format == :marshal
378
+
379
+ case config.format
380
+ when :marshal
381
+ @last_recv_uncompressed = parts.first.bytesize
382
+ parts = Marshal.load(parts.first)
383
+ end
384
+
330
385
  transient_ready!
331
386
  parts
332
387
  end
@@ -392,10 +447,6 @@ module OMQ
392
447
 
393
448
  def output(parts)
394
449
  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
450
  $stdout.write(@fmt.encode(parts))
400
451
  $stdout.flush
401
452
  end
@@ -473,33 +524,33 @@ module OMQ
473
524
  # -vv log connect/disconnect/retry/timeout events
474
525
  # -vvv also log message sent/received traces
475
526
  # --timestamps[=s|ms|us]: prepend UTC timestamps to log lines
527
+ #
528
+ # :message_received and :message_sent are not *logged* from the
529
+ # monitor fiber — #trace_recv / #trace_send render them inline
530
+ # on the same fiber as the body write, so trace-then-body
531
+ # ordering is strict on a shared tty. The monitor-fiber path
532
+ # suffered from $stderr/$stdout buffer races and from dumping
533
+ # wire-side bytes (pre-Marshal.load on recv, post-Marshal.dump
534
+ # on send) instead of app-level parts. We still *observe* these
535
+ # events here to side-channel the compressed wire_size — for
536
+ # :message_received the event fires before the recv queue
537
+ # enqueue (engine/recv_pump.rb), so by the time @sock.receive
538
+ # returns, @last_recv_wire_size reflects the current message.
476
539
  def start_event_monitor
477
- trace = config.verbose >= 3
478
- log_events = config.verbose >= 2
540
+ trace = config.verbose >= 3
541
+ log_events = config.verbose >= 2
479
542
  @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
543
+ case event.type
544
+ when :message_received
545
+ @last_recv_wire_size = event.detail[:wire_size]
546
+ when :message_sent
547
+ @last_send_wire_size = event.detail[:wire_size]
548
+ else
549
+ Term.write_event(event, config.timestamps) if log_events
484
550
  end
485
- kill_on_protocol_error(event)
551
+ SocketSetup.kill_on_protocol_error(@sock, event)
486
552
  end
487
553
  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
503
554
  end
504
555
  end
505
556
  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,26 +46,28 @@ 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
- result.map(&:to_s)
52
+ result.map { |part| part.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.
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
- result.map(&:to_s)
70
+ result.map { |part| part.to_s }
69
71
  end
70
72
 
71
73
 
@@ -77,22 +79,8 @@ module OMQ
77
79
  def self.compile_inside_ractor(src)
78
80
  return [nil, nil, nil] unless src
79
81
 
80
- extract = ->(expr, kw) {
81
- s = expr.index(/#{kw}\s*\{/)
82
- return [expr, nil] unless s
83
- ci = expr.index("{", s)
84
- depth = 1
85
- j = ci + 1
86
- while j < expr.length && depth > 0
87
- depth += 1 if expr[j] == "{"
88
- depth -= 1 if expr[j] == "}"
89
- j += 1
90
- end
91
- [expr[0...s] + expr[j..], expr[(ci + 1)..(j - 2)]]
92
- }
93
-
94
- expr, begin_body = extract.(src, "BEGIN")
95
- expr, end_body = extract.(expr, "END")
82
+ expr, begin_body = extract_block(src, "BEGIN")
83
+ expr, end_body = extract_block(expr, "END")
96
84
 
97
85
  begin_proc = eval("proc { #{begin_body} }") if begin_body
98
86
  end_proc = eval("proc { #{end_body} }") if end_body
@@ -105,17 +93,13 @@ module OMQ
105
93
  end
106
94
 
107
95
 
108
- private
109
-
110
-
111
- def extract_blocks(expr)
112
- expr, begin_body = extract_block(expr, "BEGIN")
113
- expr, end_body = extract_block(expr, "END")
114
- [expr, begin_body, end_body]
115
- end
116
-
117
-
118
- def extract_block(expr, keyword)
96
+ # Strips a +BEGIN {...}+ or +END {...}+ block from +expr+ and
97
+ # returns +[trimmed_expr, block_body_or_nil]+. Brace-matched scan,
98
+ # so nested `{}` inside the block body are handled. Shared by
99
+ # instance and Ractor compile paths, so must be a class method
100
+ # (Ractors cannot call back into instance state).
101
+ #
102
+ def self.extract_block(expr, keyword)
119
103
  start = expr.index(/#{keyword}\s*\{/)
120
104
  return [expr, nil] unless start
121
105
 
@@ -136,6 +120,16 @@ module OMQ
136
120
  trimmed = expr[0...start] + expr[j..]
137
121
  [trimmed, body]
138
122
  end
123
+
124
+
125
+ private
126
+
127
+
128
+ def extract_blocks(expr)
129
+ expr, begin_body = self.class.extract_block(expr, "BEGIN")
130
+ expr, end_body = self.class.extract_block(expr, "END")
131
+ [expr, begin_body, end_body]
132
+ end
139
133
  end
140
134
  end
141
135
  end
@@ -20,19 +20,20 @@ module OMQ
20
20
  def encode(parts)
21
21
  case @format
22
22
  when :ascii
23
- parts.map { |p| p.b.gsub(/[^[:print:]\t]/, ".") }.join("\t") + "\n"
23
+ parts.map { |p| p.b.gsub(/[^[:print:]\t]/, ".") }.join("\t") << "\n"
24
24
  when :quoted
25
- parts.map { |p| p.b.dump[1..-2] }.join("\t") + "\n"
25
+ parts.map { |p| p.b.dump[1..-2] }.join("\t") << "\n"
26
26
  when :raw
27
27
  parts.each_with_index.map do |p, i|
28
28
  Protocol::ZMTP::Codec::Frame.new(p.to_s, more: i < parts.size - 1).to_wire
29
29
  end.join
30
30
  when :jsonl
31
- JSON.generate(parts) + "\n"
31
+ JSON.generate(parts) << "\n"
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,102 @@ 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.
106
+ # For marshal, +uncompressed_size+ is the Marshal.dump bytesize
107
+ # (known to the caller, which already serialized for send or
108
+ # received the wire frame for recv) — passed through instead of
109
+ # redumping here.
86
110
  #
87
- # @param parts [Array<String>] plaintext message frames
111
+ # @param parts [Array<String, Object>, Object] message frames, or raw object when +format+ is :marshal
112
+ # @param format [Symbol, nil] active CLI format (:marshal enables object-inspect mode)
88
113
  # @param wire_size [Integer, nil] compressed bytes on the wire
114
+ # @param uncompressed_size [Integer, nil] plaintext bytes (marshal only)
89
115
  # @return [String] truncated preview of each frame joined by |
90
- def self.preview(parts, wire_size: nil)
91
- total = parts.sum(&:bytesize)
116
+ def self.preview(parts, format: nil, wire_size: nil, uncompressed_size: nil)
117
+ case format
118
+ when :marshal
119
+ marshal_preview(parts, uncompressed_size: uncompressed_size, wire_size: wire_size)
120
+ else
121
+ frames_preview(parts, format: format, wire_size: wire_size)
122
+ end
123
+ end
124
+
125
+
126
+ def self.marshal_preview(parts, uncompressed_size:, wire_size:)
127
+ inspected = parts.inspect
128
+ truncated = inspected.bytesize > 60
129
+ inspected = inspected.byteslice(0, 60) if truncated
130
+ body = sanitize(inspected)
131
+
132
+ body << "…" if truncated
133
+
134
+ header = case
135
+ when uncompressed_size && wire_size
136
+ "(#{uncompressed_size}B wire=#{wire_size}B marshal)"
137
+ when uncompressed_size
138
+ "(#{uncompressed_size}B marshal)"
139
+ else
140
+ "(marshal)"
141
+ end
142
+
143
+ "#{header} #{body}"
144
+ end
145
+ private_class_method :marshal_preview
146
+
147
+
148
+ def self.frames_preview(parts, format:, wire_size:)
92
149
  nparts = parts.size
93
150
  shown = parts.first(3).map { |p| preview_frame(p) }
94
151
  tail = nparts > 3 ? "|…" : ""
95
- size = wire_size ? "#{total}B wire=#{wire_size}B" : "#{total}B"
152
+ total = parts.all?(String) ? parts.sum { |p| p.bytesize } : nil
153
+ size = if wire_size && total
154
+ "#{total}B wire=#{wire_size}B"
155
+ elsif total
156
+ "#{total}B"
157
+ else
158
+ "#{nparts}obj"
159
+ end
96
160
  header = nparts > 1 ? "(#{size} #{nparts}F)" : "(#{size})"
97
161
 
98
162
  "#{header} #{shown.join("|")}#{tail}"
99
163
  end
164
+ private_class_method :frames_preview
100
165
 
101
166
 
167
+ # Renders one frame or decoded object for {Formatter.preview}.
168
+ # Strings are sanitized byte-wise (first 12 bytes); non-String
169
+ # objects fall back to #inspect (always single-line) truncated
170
+ # at 24 bytes.
171
+ #
172
+ # @param part [String, Object]
173
+ # @return [String]
102
174
  def self.preview_frame(part)
175
+ unless part.is_a?(String)
176
+ s = part.inspect
177
+ return s.bytesize > 24 ? "#{s.byteslice(0, 24)}…" : s
178
+ end
179
+
103
180
  bytes = part.b
104
181
  # Empty frames must render as a visible marker, not as the empty
105
182
  # string — otherwise joining with "|" would produce misleading
@@ -113,11 +190,24 @@ module OMQ
113
190
  if printable < sample.bytesize / 2
114
191
  "[#{bytes.bytesize}B]"
115
192
  elsif bytes.bytesize > 12
116
- "#{sample.gsub(/[^[:print:]]/, ".")}…"
193
+ "#{sanitize(sample)}…"
117
194
  else
118
- sample.gsub(/[^[:print:]]/, ".")
195
+ sanitize(sample)
119
196
  end
120
197
  end
198
+
199
+
200
+ # Escapes bytes so a preview/body line is guaranteed single-line
201
+ # on a shared tty. Tab/newline/CR/backslash render as literal
202
+ # \t/\n/\r/\\; other non-printables collapse to '.'. Forced to
203
+ # binary encoding first to prevent UTF-8 quirks from rendering
204
+ # raw LF bytes.
205
+ #
206
+ # @param bytes [String]
207
+ # @return [String]
208
+ def self.sanitize(bytes)
209
+ bytes.b.gsub(/[\t\n\r\\]/, LINE_ESCAPES).tr("^ -~", ".")
210
+ end
121
211
  end
122
212
  end
123
213
  end
@@ -70,22 +70,11 @@ module OMQ
70
70
  log_events = @config.verbose >= 2
71
71
  @sock.monitor(verbose: trace) do |event|
72
72
  @log_port.send(OMQ::CLI::Term.format_event(event, @config.timestamps)) if log_events
73
- kill_on_protocol_error(event)
73
+ OMQ::CLI::SocketSetup.kill_on_protocol_error(@sock, event)
74
74
  end
75
75
  end
76
76
 
77
77
 
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)
86
- end
87
-
88
-
89
78
  def wait_for_peer
90
79
  if @config.timeout
91
80
  Fiber.scheduler.with_timeout(@config.timeout) do
data/lib/omq/cli/pipe.rb CHANGED
@@ -203,18 +203,10 @@ module OMQ
203
203
  [@pull, @push].each do |sock|
204
204
  sock.monitor(verbose: trace) do |event|
205
205
  Term.write_event(event, config.timestamps) if log_events
206
- kill_on_protocol_error(sock, event)
206
+ SocketSetup.kill_on_protocol_error(sock, event)
207
207
  end
208
208
  end
209
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
218
210
  end
219
211
  end
220
212
  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
@@ -179,6 +179,24 @@ module OMQ
179
179
  $stderr.puts "OMQ_SERVER_KEY='#{Protocol::ZMTP::Z85.encode(server_pub)}'"
180
180
  end
181
181
  end
182
+
183
+
184
+ # CLI-level policy: a peer that commits a protocol-level violation
185
+ # (Protocol::ZMTP::Error — oversized frame, decompression bytebomb,
186
+ # bad framing, …) is almost certainly a misconfiguration the user
187
+ # needs to see. Mark +sock+ dead so the next receive raises
188
+ # SocketDeadError. The library itself just drops the connection and
189
+ # keeps serving the others; this stricter policy is CLI-only.
190
+ #
191
+ # @param sock [OMQ::Socket]
192
+ # @param event [OMQ::MonitorEvent]
193
+ #
194
+ def self.kill_on_protocol_error(sock, event)
195
+ return unless event.type == :disconnected
196
+ error = event.detail && event.detail[:error]
197
+ return unless error.is_a?(Protocol::ZMTP::Error)
198
+ sock.engine.signal_fatal_error(error)
199
+ end
182
200
  end
183
201
  end
184
202
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OMQ
4
4
  module CLI
5
- VERSION = "0.14.0"
5
+ VERSION = "0.14.2"
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -18,7 +18,7 @@ dependencies:
18
18
  version: '0.19'
19
19
  - - ">="
20
20
  - !ruby/object:Gem::Version
21
- version: 0.19.2
21
+ version: 0.19.3
22
22
  type: :runtime
23
23
  prerelease: false
24
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -28,7 +28,7 @@ dependencies:
28
28
  version: '0.19'
29
29
  - - ">="
30
30
  - !ruby/object:Gem::Version
31
- version: 0.19.2
31
+ version: 0.19.3
32
32
  - !ruby/object:Gem::Dependency
33
33
  name: omq-ffi
34
34
  requirement: !ruby/object:Gem::Requirement