omq-cli 0.14.1 → 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: 5352d1d8bdbab1b64aa1e7a36afcc1c70619eb0ae8373d78e95c77669d6a4fec
4
- data.tar.gz: 38172d3bbff810e3be322e3b8aa4e6d9e09520976d76b34e6721606dd8a15c95
3
+ metadata.gz: 23eb8d602298e5db1259e6d7d0effe08e8e0dfacba55b8d9a5287800290f2eb9
4
+ data.tar.gz: b492d78fd4997c82434853bc284f7f98c662d905a450e3545c1df2ae822bf40c
5
5
  SHA512:
6
- metadata.gz: 158eb775b0ccea84275a69dc39e8279f41fab5f9bfeb94e8ca0c9e5dd7342b6472980e925850b7d82eceb509981942c42cd80abcb52a9e871b335075898a05b6
7
- data.tar.gz: 8885731e2d99e87038bb6cffe9f4dc7edd480d285d4b2ac5b5f2244772433f70123e1ed49e9da7a9aa55944aab938bbbf10d7acc98975a04df6b45fd0a1d6b7c
6
+ metadata.gz: a3abd80d9856c52e2674c86a3e31ed4f02d6511cad2e3d9f35a2ab5b65faaf07acdc4c442f3746bc5ed0dd56e730df0fabc4930c0cdfe05ad8e289335fc0e8d4
7
+ data.tar.gz: 98e5cf25c2e267aace41bb958843459ead62da2a338b8a64aa3f6fc3af571dfefaee30b01a0de720b8880a96c6c0c3b55fe7a8439a6b5151a54085233ded3aaa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
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
+
3
44
  ## 0.14.1 — 2026-04-13
4
45
 
5
46
  ### Changed
@@ -304,9 +304,19 @@ module OMQ
304
304
  # may write to stdout (e.g. `-e 'p it'`), and we want the
305
305
  # trace line to precede any such output so the sequence on the
306
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.
307
313
  def trace_recv(parts)
308
314
  return unless config.verbose >= 3
309
- $stderr.write("#{Term.log_prefix(config.timestamps)}omq: << #{Formatter.preview(parts, format: config.format)}\n")
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")
310
320
  $stderr.flush
311
321
  end
312
322
 
@@ -331,8 +341,9 @@ module OMQ
331
341
  def send_msg(parts)
332
342
  case config.format
333
343
  when :marshal
334
- trace_send(parts)
335
- @sock.send([Marshal.dump(parts)])
344
+ dumped = Marshal.dump(parts)
345
+ trace_send(parts, uncompressed_size: dumped.bytesize)
346
+ @sock.send([dumped])
336
347
  else
337
348
  return if parts.empty?
338
349
  trace_send(parts)
@@ -342,12 +353,21 @@ module OMQ
342
353
  end
343
354
 
344
355
 
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)
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)
349
365
  return unless config.verbose >= 3
350
- $stderr.write("#{Term.log_prefix(config.timestamps)}omq: >> #{Formatter.preview(parts, format: config.format)}\n")
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")
351
371
  $stderr.flush
352
372
  end
353
373
 
@@ -355,7 +375,13 @@ module OMQ
355
375
  def recv_msg
356
376
  parts = @sock.receive
357
377
  return nil if parts.nil?
358
- 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
+
359
385
  transient_ready!
360
386
  parts
361
387
  end
@@ -499,37 +525,32 @@ module OMQ
499
525
  # -vvv also log message sent/received traces
500
526
  # --timestamps[=s|ms|us]: prepend UTC timestamps to log lines
501
527
  #
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
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
505
531
  # ordering is strict on a shared tty. The monitor-fiber path
506
532
  # suffered from $stderr/$stdout buffer races and from dumping
507
533
  # 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
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.
510
539
  def start_event_monitor
511
540
  trace = config.verbose >= 3
512
541
  log_events = config.verbose >= 2
513
542
  @sock.monitor(verbose: trace) do |event|
514
- Term.write_event(event, config.timestamps) if log_events && !SKIP_MONITOR_EVENTS.include?(event.type)
515
- kill_on_protocol_error(event)
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
550
+ end
551
+ SocketSetup.kill_on_protocol_error(@sock, event)
516
552
  end
517
553
  end
518
-
519
-
520
- # omq-cli policy: a peer that commits a protocol-level violation
521
- # (Protocol::ZMTP::Error — oversized frame, decompression
522
- # bytebomb, bad framing, …) is almost certainly a
523
- # misconfiguration the user needs to see. Mark the socket dead
524
- # so the next receive raises SocketDeadError. The library
525
- # itself just drops the connection and keeps serving the
526
- # others; this stricter policy is CLI-only.
527
- def kill_on_protocol_error(event)
528
- return unless event.type == :disconnected
529
- error = event.detail && event.detail[:error]
530
- return unless error.is_a?(Protocol::ZMTP::Error)
531
- @sock.engine.signal_fatal_error(error)
532
- end
533
554
  end
534
555
  end
535
556
  end
@@ -49,7 +49,7 @@ 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 { |part| part.to_s }
53
53
  rescue => e
54
54
  $stderr.puts "omq: eval error: #{e.message} (#{e.class})"
55
55
  exit 3
@@ -67,7 +67,7 @@ module OMQ
67
67
  return nil if result.nil?
68
68
  return result if format == :marshal
69
69
  result = result.is_a?(Array) ? result : [result]
70
- result.map(&:to_s)
70
+ result.map { |part| part.to_s }
71
71
  end
72
72
 
73
73
 
@@ -79,22 +79,8 @@ module OMQ
79
79
  def self.compile_inside_ractor(src)
80
80
  return [nil, nil, nil] unless src
81
81
 
82
- extract = ->(expr, kw) {
83
- s = expr.index(/#{kw}\s*\{/)
84
- return [expr, nil] unless s
85
- ci = expr.index("{", s)
86
- depth = 1
87
- j = ci + 1
88
- while j < expr.length && depth > 0
89
- depth += 1 if expr[j] == "{"
90
- depth -= 1 if expr[j] == "}"
91
- j += 1
92
- end
93
- [expr[0...s] + expr[j..], expr[(ci + 1)..(j - 2)]]
94
- }
95
-
96
- expr, begin_body = extract.(src, "BEGIN")
97
- expr, end_body = extract.(expr, "END")
82
+ expr, begin_body = extract_block(src, "BEGIN")
83
+ expr, end_body = extract_block(expr, "END")
98
84
 
99
85
  begin_proc = eval("proc { #{begin_body} }") if begin_body
100
86
  end_proc = eval("proc { #{end_body} }") if end_body
@@ -107,17 +93,13 @@ module OMQ
107
93
  end
108
94
 
109
95
 
110
- private
111
-
112
-
113
- def extract_blocks(expr)
114
- expr, begin_body = extract_block(expr, "BEGIN")
115
- expr, end_body = extract_block(expr, "END")
116
- [expr, begin_body, end_body]
117
- end
118
-
119
-
120
- 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)
121
103
  start = expr.index(/#{keyword}\s*\{/)
122
104
  return [expr, nil] unless start
123
105
 
@@ -138,6 +120,16 @@ module OMQ
138
120
  trimmed = expr[0...start] + expr[j..]
139
121
  [trimmed, body]
140
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
141
133
  end
142
134
  end
143
135
  end
@@ -20,20 +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
35
  # Under -M, `parts` is a single Ruby object (not a frame array).
36
- parts.inspect + "\n"
36
+ parts.inspect << "\n"
37
37
  end
38
38
  end
39
39
 
@@ -103,37 +103,65 @@ module OMQ
103
103
  # itself (not an Array of frames); the preview inspects it so
104
104
  # the reader sees the actual payload structure (e.g.
105
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.
106
110
  #
107
111
  # @param parts [Array<String, Object>, Object] message frames, or raw object when +format+ is :marshal
108
112
  # @param format [Symbol, nil] active CLI format (:marshal enables object-inspect mode)
109
113
  # @param wire_size [Integer, nil] compressed bytes on the wire
114
+ # @param uncompressed_size [Integer, nil] plaintext bytes (marshal only)
110
115
  # @return [String] truncated preview of each frame joined by |
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
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)
119
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
+
120
147
 
148
+ def self.frames_preview(parts, format:, wire_size:)
121
149
  nparts = parts.size
122
150
  shown = parts.first(3).map { |p| preview_frame(p) }
123
151
  tail = nparts > 3 ? "|…" : ""
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
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
133
160
  header = nparts > 1 ? "(#{size} #{nparts}F)" : "(#{size})"
134
161
 
135
162
  "#{header} #{shown.join("|")}#{tail}"
136
163
  end
164
+ private_class_method :frames_preview
137
165
 
138
166
 
139
167
  # Renders one frame or decoded object for {Formatter.preview}.
@@ -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
@@ -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.1"
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.1
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