openc3 7.4.0 → 7.5.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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +4 -3
  3. data/bin/pipinstall +18 -5
  4. data/bin/pipuninstall +1 -1
  5. data/bin/uvinstall +36 -8
  6. data/data/config/default_plugins.yaml +43 -0
  7. data/data/config/interface_modifiers.yaml +22 -7
  8. data/data/config/item_modifiers.yaml +14 -4
  9. data/data/config/plugins.yaml +1 -1
  10. data/data/config/widgets.yaml +1 -1
  11. data/lib/openc3/api/cmd_api.rb +10 -13
  12. data/lib/openc3/api/interface_api.rb +1 -2
  13. data/lib/openc3/api/limits_api.rb +1 -1
  14. data/lib/openc3/api/metrics_api.rb +26 -1
  15. data/lib/openc3/api/router_api.rb +1 -2
  16. data/lib/openc3/api/tlm_api.rb +21 -21
  17. data/lib/openc3/config/config_parser.rb +3 -1
  18. data/lib/openc3/core_ext/tempfile.rb +2 -0
  19. data/lib/openc3/interfaces/file_interface.rb +6 -1
  20. data/lib/openc3/interfaces/http_client_interface.rb +14 -6
  21. data/lib/openc3/interfaces/interface.rb +27 -3
  22. data/lib/openc3/interfaces/protocols/protocol.rb +8 -0
  23. data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
  24. data/lib/openc3/interfaces/stream_interface.rb +22 -3
  25. data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
  26. data/lib/openc3/interfaces/udp_interface.rb +16 -2
  27. data/lib/openc3/interfaces.rb +1 -0
  28. data/lib/openc3/io/json_api_object.rb +24 -3
  29. data/lib/openc3/io/json_drb_object.rb +3 -2
  30. data/lib/openc3/logs/log_writer.rb +13 -4
  31. data/lib/openc3/microservices/decom_microservice.rb +7 -4
  32. data/lib/openc3/microservices/interface_microservice.rb +146 -50
  33. data/lib/openc3/microservices/log_microservice.rb +1 -3
  34. data/lib/openc3/microservices/microservice.rb +25 -1
  35. data/lib/openc3/microservices/plugin_microservice.rb +32 -0
  36. data/lib/openc3/microservices/text_log_microservice.rb +1 -3
  37. data/lib/openc3/models/interface_model.rb +1 -1
  38. data/lib/openc3/models/interface_status_model.rb +6 -0
  39. data/lib/openc3/models/metric_model.rb +52 -4
  40. data/lib/openc3/models/microservice_model.rb +7 -3
  41. data/lib/openc3/models/plugin_model.rb +12 -12
  42. data/lib/openc3/models/python_package_model.rb +3 -13
  43. data/lib/openc3/models/reaction_model.rb +6 -3
  44. data/lib/openc3/models/setting_model.rb +5 -3
  45. data/lib/openc3/models/target_model.rb +83 -9
  46. data/lib/openc3/operators/microservice_operator.rb +5 -0
  47. data/lib/openc3/operators/operator.rb +69 -0
  48. data/lib/openc3/packets/packet.rb +10 -4
  49. data/lib/openc3/packets/packet_config.rb +48 -2
  50. data/lib/openc3/packets/packet_item.rb +27 -14
  51. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  52. data/lib/openc3/packets/parsers/xtce_parser.rb +8 -1
  53. data/lib/openc3/packets/structure_item.rb +3 -2
  54. data/lib/openc3/script/api_shared.rb +58 -84
  55. data/lib/openc3/script/extract.rb +215 -19
  56. data/lib/openc3/script/plugins.rb +4 -11
  57. data/lib/openc3/script/storage.rb +81 -10
  58. data/lib/openc3/script/tables.rb +5 -1
  59. data/lib/openc3/system/system.rb +9 -6
  60. data/lib/openc3/topics/command_decom_topic.rb +1 -1
  61. data/lib/openc3/topics/command_topic.rb +2 -1
  62. data/lib/openc3/topics/router_topic.rb +2 -1
  63. data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
  64. data/lib/openc3/topics/telemetry_topic.rb +3 -2
  65. data/lib/openc3/topics/topic.rb +154 -2
  66. data/lib/openc3/utilities/cli_generator.rb +67 -15
  67. data/lib/openc3/utilities/config_overlay.rb +35 -17
  68. data/lib/openc3/utilities/default_plugins.rb +33 -0
  69. data/lib/openc3/utilities/local_mode.rb +82 -46
  70. data/lib/openc3/utilities/metric.rb +16 -0
  71. data/lib/openc3/utilities/process_stats.rb +160 -0
  72. data/lib/openc3/utilities/pypi_url.rb +65 -0
  73. data/lib/openc3/utilities/questdb_client.rb +43 -46
  74. data/lib/openc3/utilities/running_script.rb +22 -18
  75. data/lib/openc3/utilities/store_autoload.rb +20 -3
  76. data/lib/openc3/utilities/store_queued.rb +37 -9
  77. data/lib/openc3/utilities/target_file.rb +57 -12
  78. data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
  79. data/lib/openc3/version.rb +5 -5
  80. data/templates/conversion/conversion.py +6 -2
  81. data/templates/conversion/conversion.rb +5 -0
  82. data/templates/plugin/.github/workflows/playwright.yml +62 -0
  83. data/templates/plugin/.github/workflows/release.yml +45 -0
  84. data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
  85. data/templates/plugin/.gitignore +6 -0
  86. data/templates/plugin/README.md +114 -2
  87. data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
  88. data/templates/plugin/plugin.gemspec +10 -1
  89. data/templates/plugin/pyproject.toml +119 -0
  90. data/templates/tool_angular/package.json +2 -2
  91. data/templates/tool_angular/pnpm-workspace.yaml +16 -0
  92. data/templates/tool_react/package.json +1 -1
  93. data/templates/tool_react/pnpm-workspace.yaml +16 -0
  94. data/templates/tool_svelte/package.json +2 -1
  95. data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
  96. data/templates/tool_vue/package.json +3 -3
  97. data/templates/tool_vue/pnpm-workspace.yaml +16 -0
  98. data/templates/widget/package.json +2 -2
  99. data/templates/widget/pnpm-workspace.yaml +16 -0
  100. metadata +36 -1
@@ -70,6 +70,9 @@ module OpenC3
70
70
  # this interface
71
71
  attr_accessor :disable_disconnect
72
72
 
73
+ # @return [Boolean] Flag indicating if the interface is allowed to read
74
+ attr_writer :read_allowed
75
+
73
76
  # @return [StreamLogPair] StreamLogPair instance or nil
74
77
  attr_accessor :stream_log_pair
75
78
 
@@ -326,6 +329,12 @@ module OpenC3
326
329
  # Return packet
327
330
  @read_count += 1
328
331
  Logger.warn("#{@name}: Interface unexpectedly requested disconnect") unless packet
332
+ # Timestamp buffered reads with when the data arrived rather than when
333
+ # it was processed (otherwise received_time is set when it is handled)
334
+ if packet and !packet.received_time
335
+ data_time = read_queue_data_time
336
+ packet.received_time = data_time if data_time
337
+ end
329
338
  return packet
330
339
  end
331
340
  rescue Exception => e
@@ -349,7 +358,8 @@ module OpenC3
349
358
  if packet == :DISCONNECT
350
359
  Logger.info("#{@name}: Protocol #{protocol.class} write_packet requested disconnect")
351
360
  disconnect()
352
- return
361
+ # Nothing was written so reject rather than report success
362
+ raise WriteRejectError.new("Protocol #{protocol.class} write_packet requested disconnect")
353
363
  end
354
364
  return if packet == :STOP
355
365
  end
@@ -369,7 +379,8 @@ module OpenC3
369
379
  if data == :DISCONNECT
370
380
  Logger.info("#{@name}: Protocol #{protocol.class} write_data requested disconnect")
371
381
  disconnect()
372
- return
382
+ # Nothing was written so reject rather than report success
383
+ raise WriteRejectError.new("Protocol #{protocol.class} write_data requested disconnect")
373
384
  end
374
385
  return if data == :STOP
375
386
  end
@@ -443,6 +454,19 @@ module OpenC3
443
454
  config
444
455
  end
445
456
 
457
+ # @return [Integer] The number of bytes waiting on the read queue.
458
+ # Interfaces which buffer raw reads override this (see ReadQueueInterface).
459
+ def read_queue_bytes
460
+ 0
461
+ end
462
+
463
+ # @return [Time, nil] When the data most recently returned by
464
+ # read_interface was read from the underlying source, or nil if unknown.
465
+ # Interfaces which buffer raw reads override this (see ReadQueueInterface).
466
+ def read_queue_data_time
467
+ nil
468
+ end
469
+
446
470
  # @return [Boolean] Whether reading is allowed
447
471
  def read_allowed?
448
472
  @read_allowed
@@ -564,7 +588,7 @@ module OpenC3
564
588
  # @return [String] Raw packet data
565
589
  def read_interface_base(data, _extra = nil)
566
590
  if @save_raw_data
567
- @read_raw_data_time = Time.now
591
+ @read_raw_data_time = read_queue_data_time || Time.now
568
592
  @read_raw_data = data.clone
569
593
  end
570
594
  @bytes_read += data.length
@@ -36,6 +36,14 @@ module OpenC3
36
36
  end
37
37
 
38
38
  def reset
39
+ @read_data_input_time = nil
40
+ @read_data_input = ''
41
+ @read_data_output_time = nil
42
+ @read_data_output = ''
43
+ @write_data_input_time = nil
44
+ @write_data_input = ''
45
+ @write_data_output_time = nil
46
+ @write_data_output = ''
39
47
  @extra = nil
40
48
  end
41
49
 
@@ -0,0 +1,331 @@
1
+ # encoding: ascii-8bit
2
+
3
+ # Copyright 2026 OpenC3, Inc.
4
+ # All Rights Reserved.
5
+ #
6
+ # This program is distributed in the hope that it will be useful,
7
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
8
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
9
+ # See LICENSE.md for more details.
10
+ #
11
+ # This file may also be used under the terms of a commercial license
12
+ # if purchased from OpenC3, Inc.
13
+
14
+ require 'timeout' # For Timeout::Error
15
+ require 'openc3/interfaces/interface'
16
+ require 'openc3/core_ext/exception'
17
+ require 'openc3/core_ext/time'
18
+ require 'openc3/utilities/logger'
19
+
20
+ module OpenC3
21
+ # Interface base class which reads an underlying socket / stream (the reader)
22
+ # in a dedicated thread and buffers the results on a queue. This keeps the
23
+ # operating system receive buffers drained as fast as possible so data isn't
24
+ # dropped while the packet reading thread is busy processing protocols and
25
+ # writing to the database.
26
+ #
27
+ # Subclasses call start_read_queue_thread with the object they read from
28
+ # (anything with a read method) once it is connected and stop_read_queue_thread
29
+ # once it is disconnected. While the thread is running the reader's read
30
+ # method is redirected to the queue, so the subclass (and anything else such
31
+ # as derived classes or protocols) keeps calling reader.read exactly as before
32
+ # and receives the data in the order it was read rather than competing with
33
+ # the read thread for it.
34
+ #
35
+ # Setting READ_QUEUE_MAX_SIZE to 0 disables the read thread so the reader is
36
+ # read inline, which is how interfaces read before the queue existed.
37
+ class ReadQueueInterface < Interface
38
+ # Maximum number of bytes buffered on the queue before the read thread
39
+ # blocks and lets the operating system do the buffering instead
40
+ DEFAULT_READ_QUEUE_MAX_SIZE = 20 * 1024 * 1024 # 20MB
41
+
42
+ # Approximate memory each queued read costs on top of the data itself (the
43
+ # String object plus the queue entry). Small reads are dominated by this so
44
+ # it is charged against the budget, otherwise a stream delivering a few
45
+ # bytes at a time would hold many times the budget in actual memory.
46
+ READ_QUEUE_ENTRY_OVERHEAD = 64
47
+
48
+ # How long to wait before re-checking whether the interface has been
49
+ # disconnected while blocked waiting for room on the queue
50
+ QUEUE_POLL_TIMEOUT = 0.1
51
+
52
+ # How long to wait for the read thread to exit when disconnecting
53
+ THREAD_JOIN_TIMEOUT = 2
54
+
55
+ # @return [Integer] Maximum number of bytes buffered on the queue
56
+ attr_accessor :read_queue_max_size
57
+
58
+ # @return [Time, nil] When the read thread read the data most recently
59
+ # returned by the reader (nil when the read queue is disabled)
60
+ attr_reader :read_queue_data_time
61
+
62
+ def initialize
63
+ super()
64
+ initialize_read_queue()
65
+ end
66
+
67
+ # Initialize (or reset) the read queue attributes. Called by initialize.
68
+ def initialize_read_queue(max_size = DEFAULT_READ_QUEUE_MAX_SIZE)
69
+ @raw_read_queue = nil
70
+ @raw_read_thread = nil
71
+ @raw_reader = nil
72
+ @raw_reader_read = nil
73
+ @raw_read_bytes = 0
74
+ @raw_read_budget = 0
75
+ @raw_read_timeout_queued = false
76
+ @read_queue_data_time = nil
77
+ # Guards @raw_read_queue / @raw_read_bytes / @raw_read_budget and wakes the
78
+ # read thread when the bytes it queued are consumed and there is room to
79
+ # queue more. Each read thread is cancelled by closing its own queue under
80
+ # this mutex, so a thread which outlives stop_read_queue_thread (and a new
81
+ # thread starting) keeps seeing its old queue closed and can never touch
82
+ # the byte counters or read again.
83
+ @raw_read_mutex = Mutex.new
84
+ @raw_read_condition = ConditionVariable.new
85
+ @read_queue_max_size = max_size
86
+ end
87
+
88
+ # Supported Options
89
+ # READ_QUEUE_MAX_SIZE - Maximum number of bytes buffered on the read queue
90
+ # or 0 to disable the read thread and read inline (see Interface#set_option)
91
+ def set_option(option_name, option_values)
92
+ super(option_name, option_values)
93
+ if option_name.upcase == 'READ_QUEUE_MAX_SIZE'
94
+ max_size = Integer(option_values[0])
95
+ raise "READ_QUEUE_MAX_SIZE must be 0 (disabled) or a positive integer but was #{max_size}" if max_size < 0
96
+
97
+ @read_queue_max_size = max_size
98
+ end
99
+ end
100
+
101
+ # @return [Boolean] Whether reads are buffered by a read thread (false when
102
+ # READ_QUEUE_MAX_SIZE is 0 and reads happen inline)
103
+ def read_queue_enabled?
104
+ @read_queue_max_size > 0
105
+ end
106
+
107
+ # @return [Boolean] Whether a read thread has been started (and not stopped)
108
+ def read_queue_started?
109
+ !@raw_read_queue.nil?
110
+ end
111
+
112
+ # @return [Integer] The number of reads waiting on the read queue
113
+ def read_queue_size
114
+ queue = @raw_read_queue
115
+ queue ? queue.size : 0
116
+ end
117
+
118
+ # @return [Integer] The number of bytes waiting on the read queue
119
+ def read_queue_bytes
120
+ return 0 unless @raw_read_queue
121
+
122
+ @raw_read_mutex.synchronize { @raw_read_bytes }
123
+ end
124
+
125
+ # Start the thread which continuously reads and queues data from the reader
126
+ # and redirect the reader's read method to the queue. Does nothing beyond
127
+ # stopping any existing thread if the read queue is disabled.
128
+ #
129
+ # @param reader [#read] Object to read from, e.g. a Stream or socket
130
+ # @param read_args [Array] Arguments the read thread passes to reader.read.
131
+ # Arguments passed to the redirected read are ignored.
132
+ def start_read_queue_thread(reader, *read_args)
133
+ stop_read_queue_thread()
134
+ return unless read_queue_enabled?
135
+
136
+ queue = Queue.new
137
+ @raw_read_mutex.synchronize do
138
+ @raw_read_bytes = 0
139
+ @raw_read_budget = 0
140
+ @raw_read_timeout_queued = false
141
+ @raw_read_queue = queue
142
+ end
143
+ begin
144
+ raw_read = redirect_reader(reader)
145
+ rescue FrozenError, TypeError
146
+ Logger.warn "#{@name}: Can't redirect #{reader.class}#read so disabling the read queue"
147
+ @raw_read_mutex.synchronize { @raw_read_queue = nil }
148
+ @read_queue_max_size = 0
149
+ return
150
+ end
151
+ @raw_read_thread = Thread.new do
152
+ read_queue_thread_body(queue, raw_read, read_args)
153
+ rescue Exception => e
154
+ Logger.error "#{@name}: Read queue thread unexpectedly died: #{e.formatted}"
155
+ end
156
+ end
157
+
158
+ # Stop the read thread, restore the reader's read method and discard anything
159
+ # left on the queue. The reader should be disconnected first so the thread
160
+ # isn't blocked reading.
161
+ def stop_read_queue_thread
162
+ # Allow subclasses to set their reader before initialize_read_queue
163
+ return unless @raw_read_mutex
164
+
165
+ @raw_read_mutex.synchronize do
166
+ # Closing the queue under the mutex cancels the read thread (it either
167
+ # already charged its bytes, which are reset below, or never will) and
168
+ # unblocks read_queue_pop
169
+ @raw_read_queue.close if @raw_read_queue
170
+ @raw_read_queue = nil
171
+ @raw_read_bytes = 0
172
+ @raw_read_budget = 0
173
+ @raw_read_timeout_queued = false
174
+ @read_queue_data_time = nil
175
+ # Unblock the read thread if it is waiting for room on the queue
176
+ @raw_read_condition.broadcast
177
+ end
178
+ restore_reader()
179
+ thread = @raw_read_thread
180
+ @raw_read_thread = nil
181
+ # The read source should already be disconnected which unblocks the read
182
+ if thread and thread != Thread.current and !thread.join(THREAD_JOIN_TIMEOUT)
183
+ OpenC3.kill_thread(nil, thread)
184
+ end
185
+ end
186
+
187
+ # Retrieve the next data off the read queue, blocking until it is available.
188
+ # This is what the reader's read method returns while it is redirected.
189
+ #
190
+ # @return [String, nil] Data read or nil if the read source is done
191
+ def read_queue_pop
192
+ queue = @raw_read_queue
193
+ return nil unless queue
194
+
195
+ loop do
196
+ data = queue.pop
197
+ if data.kind_of?(Array)
198
+ data, time = data
199
+ @raw_read_mutex.synchronize do
200
+ # Counters were reset if the queue was stopped / replaced
201
+ if queue.equal?(@raw_read_queue)
202
+ @raw_read_bytes -= data.length
203
+ @raw_read_budget -= data.length + READ_QUEUE_ENTRY_OVERHEAD
204
+ @read_queue_data_time = time
205
+ # Tell the read thread there is room for more data
206
+ @raw_read_condition.broadcast
207
+ end
208
+ end
209
+ return data
210
+ end
211
+
212
+ if data.kind_of?(Timeout::Error)
213
+ @raw_read_mutex.synchronize { @raw_read_timeout_queued = false if queue.equal?(@raw_read_queue) }
214
+ # Data queued behind the timeout arrived while nobody was waiting on
215
+ # it, so an inline read would have returned it rather than timing out
216
+ next unless queue.empty?
217
+ end
218
+ # Exceptions raised by the read thread are re-raised here so they are
219
+ # handled exactly as they were when the read happened inline
220
+ raise data if data.kind_of?(Exception)
221
+
222
+ return data
223
+ end
224
+ end
225
+
226
+ # @param data [String, nil] Data returned by reader.read
227
+ # @return [Boolean] Whether the data indicates the reader is done (closed) so
228
+ # the read thread should stop. Subclasses can override this.
229
+ def read_queue_done?(data)
230
+ data.nil? or data.length <= 0
231
+ end
232
+
233
+ # protected
234
+
235
+ def read_queue_thread_body(queue, raw_read, read_args)
236
+ until queue.closed?
237
+ begin
238
+ data = raw_read.call(*read_args)
239
+ data_time = Time.now.sys
240
+ rescue Timeout::Error => e
241
+ # A read timeout doesn't end the reader so pass it along and keep
242
+ # reading. Only one is queued at a time so a reader which times out
243
+ # quickly can't fill the queue with them.
244
+ queue_timeout = @raw_read_mutex.synchronize do
245
+ next false if queue.closed? or @raw_read_timeout_queued
246
+
247
+ @raw_read_timeout_queued = true
248
+ end
249
+ queue.push(e) if queue_timeout
250
+ next
251
+ rescue Exception => e
252
+ queue.push(e)
253
+ break
254
+ end
255
+ # Tell whoever reads next that the reader is done and stop reading
256
+ if read_queue_done?(data)
257
+ queue.push(data)
258
+ break
259
+ end
260
+ # Charge the bytes against the budget before the push so
261
+ # read_queue_bytes never goes negative if the data is dequeued
262
+ # before we get back here
263
+ break unless reserve_read_queue_bytes(queue, data.length)
264
+
265
+ # Queue the time of the read so packets are timestamped when the data
266
+ # arrived rather than when it was processed
267
+ queue.push([data, data_time])
268
+ end
269
+ rescue ClosedQueueError
270
+ # Interface disconnected while we were pushing
271
+ ensure
272
+ # Closing tells read_queue_pop there will never be more data so it
273
+ # returns nil rather than blocking forever
274
+ queue.close
275
+ end
276
+
277
+ # Wait until the data fits in the byte budget and charge it against the
278
+ # budget. A read larger than the entire budget is queued on its own rather
279
+ # than never fitting, so the budget can be exceeded by at most one read.
280
+ #
281
+ # @return [Boolean] Whether the bytes were reserved (false if disconnected)
282
+ def reserve_read_queue_bytes(queue, length)
283
+ cost = length + READ_QUEUE_ENTRY_OVERHEAD
284
+ @raw_read_mutex.synchronize do
285
+ while @raw_read_budget > 0 and (@raw_read_budget + cost) > @read_queue_max_size
286
+ return false if queue.closed?
287
+
288
+ @raw_read_condition.wait(@raw_read_mutex, QUEUE_POLL_TIMEOUT)
289
+ end
290
+ return false if queue.closed?
291
+
292
+ @raw_read_bytes += length
293
+ @raw_read_budget += cost
294
+ end
295
+ return true
296
+ end
297
+
298
+ # Redirect reader.read to the read queue so everything which reads the
299
+ # reader directly gets the queued data in order.
300
+ #
301
+ # @return [Method] The original read method for the read thread to call
302
+ def redirect_reader(reader)
303
+ raw_read = reader.method(:read)
304
+ # Remember a read already defined on the reader itself (e.g. a test stub)
305
+ # so it can be put back rather than removed
306
+ reader_read = reader.singleton_methods(false).include?(:read) ? raw_read : nil
307
+ interface = self
308
+ reader.define_singleton_method(:read) { |*_args| interface.read_queue_pop }
309
+ @raw_reader = reader
310
+ @raw_reader_read = reader_read
311
+ return raw_read
312
+ end
313
+
314
+ # Undo redirect_reader so the reader reads inline again
315
+ def restore_reader
316
+ reader = @raw_reader
317
+ return unless reader
318
+
319
+ if @raw_reader_read
320
+ reader.define_singleton_method(:read, @raw_reader_read)
321
+ else
322
+ reader.singleton_class.send(:remove_method, :read)
323
+ end
324
+ rescue NameError
325
+ # The redirect was already removed
326
+ ensure
327
+ @raw_reader = nil
328
+ @raw_reader_read = nil
329
+ end
330
+ end
331
+ end
@@ -15,12 +15,12 @@
15
15
  # This file may also be used under the terms of a commercial license
16
16
  # if purchased from OpenC3, Inc.
17
17
 
18
- require 'openc3/interfaces/interface'
18
+ require 'openc3/interfaces/read_queue_interface'
19
19
 
20
20
  module OpenC3
21
21
  # Base class for interfaces that act read and write from a stream
22
- class StreamInterface < Interface
23
- attr_accessor :stream
22
+ class StreamInterface < ReadQueueInterface
23
+ attr_reader :stream
24
24
 
25
25
  def initialize(protocol_type = nil, protocol_args = [])
26
26
  super()
@@ -34,9 +34,20 @@ module OpenC3
34
34
  end
35
35
  end
36
36
 
37
+ # Replacing the stream stops the read thread which is reading the old one
38
+ def stream=(stream)
39
+ stop_read_queue_thread()
40
+ @stream = stream
41
+ end
42
+
37
43
  def connect
38
44
  super() # Reset the protocols
39
45
  @stream.connect if @stream
46
+ # The read thread continuously drains the stream so the operating system
47
+ # buffers don't fill up while we're busy processing the previous read.
48
+ # @stream.read is redirected to the queue so everything reading the
49
+ # stream (including subclasses and protocols) still sees all the data.
50
+ start_read_queue_thread(@stream) if @stream and read_allowed?
40
51
  end
41
52
 
42
53
  def connected?
@@ -48,11 +59,19 @@ module OpenC3
48
59
  end
49
60
 
50
61
  def disconnect
62
+ # Disconnect the stream first to unblock the read thread
51
63
  @stream.disconnect if @stream
64
+ stop_read_queue_thread()
52
65
  super()
53
66
  end
54
67
 
55
68
  def read_interface
69
+ # The read thread is normally started by connect but interfaces can also
70
+ # be handed an already connected stream so start it on demand
71
+ if @stream and !read_queue_started? and read_queue_enabled? and read_allowed? and connected?
72
+ start_read_queue_thread(@stream)
73
+ end
74
+
56
75
  timeout = false
57
76
  begin
58
77
  data = @stream.read
@@ -257,6 +257,15 @@ module OpenC3
257
257
  @read_queue ? @read_queue.size : 0
258
258
  end
259
259
 
260
+ # @return [Integer] The number of bytes waiting on the read queues of the
261
+ # connected clients. The server itself has no stream so the raw reads are
262
+ # buffered by each client connection.
263
+ def read_queue_bytes
264
+ @connection_mutex.synchronize do
265
+ @read_interface_infos.sum { |rii| rii.interface.read_queue_bytes }
266
+ end
267
+ end
268
+
260
269
  # @return [Integer] The number of packets waiting on the write queue
261
270
  def write_queue_size
262
271
  @write_queue ? @write_queue.size : 0
@@ -400,6 +409,10 @@ module OpenC3
400
409
  end
401
410
 
402
411
  interface = StreamInterface.new
412
+ # Only the read side of the connection needs a read thread
413
+ interface.read_allowed = listen_read
414
+ # Each connection buffers its own reads so give it the configured limit
415
+ interface.read_queue_max_size = @read_queue_max_size
403
416
  interface.target_names = @target_names
404
417
  interface.cmd_target_names = @cmd_target_names
405
418
  interface.tlm_target_names = @tlm_target_names
@@ -15,13 +15,13 @@
15
15
  # This file may also be used under the terms of a commercial license
16
16
  # if purchased from OpenC3, Inc.
17
17
 
18
- require 'openc3/interfaces/interface'
18
+ require 'openc3/interfaces/read_queue_interface'
19
19
  require 'openc3/io/udp_sockets'
20
20
  require 'openc3/config/config_parser'
21
21
 
22
22
  module OpenC3
23
23
  # Base class for interfaces that send and receive messages over UDP
24
- class UdpInterface < Interface
24
+ class UdpInterface < ReadQueueInterface
25
25
  HOST_127_0_0_1 = '127.0.0.1'
26
26
  HOST_0_0_0_0 = '0.0.0.0'
27
27
 
@@ -140,6 +140,11 @@ module OpenC3
140
140
  end
141
141
  @thread_sleeper = nil
142
142
  super()
143
+ # The read thread continuously drains the socket so the operating system
144
+ # buffers don't fill up (and drop datagrams) while we're busy processing
145
+ # the previous read. @read_socket.read is redirected to the queue so
146
+ # everything reading the socket (including subclasses) still sees all the data.
147
+ start_read_queue_thread(@read_socket, @read_timeout) if @read_socket
143
148
  end
144
149
 
145
150
  # @return [Boolean] Whether the active ports (read and/or write) have
@@ -163,6 +168,8 @@ module OpenC3
163
168
  OpenC3.close_socket(@read_socket)
164
169
  @write_socket = nil
165
170
  @read_socket = nil
171
+ # The sockets are closed above which unblocks the read thread
172
+ stop_read_queue_thread()
166
173
  @thread_sleeper.cancel if @thread_sleeper
167
174
  @thread_sleeper = nil
168
175
  super()
@@ -177,9 +184,16 @@ module OpenC3
177
184
  return nil
178
185
  end
179
186
 
187
+ # Zero length datagrams are valid so only nil means the socket is done
188
+ def read_queue_done?(data)
189
+ data.nil?
190
+ end
191
+
180
192
  # Reads from the socket if the read_port is defined
181
193
  def read_interface
182
194
  data = @read_socket.read(@read_timeout)
195
+ return nil unless data # Disconnected
196
+
183
197
  Logger.info "#{@name}: Udp read returned 0 bytes (stream closed)" if data.length <= 0
184
198
  extra = nil
185
199
  read_interface_base(data, extra)
@@ -22,6 +22,7 @@ module OpenC3
22
22
  autoload(:Interface, 'openc3/interfaces/interface.rb')
23
23
  autoload(:MqttInterface, 'openc3/interfaces/mqtt_interface.rb')
24
24
  autoload(:MqttStreamInterface, 'openc3/interfaces/mqtt_stream_interface.rb')
25
+ autoload(:ReadQueueInterface, 'openc3/interfaces/read_queue_interface.rb')
25
26
  autoload(:SerialInterface, 'openc3/interfaces/serial_interface.rb')
26
27
  autoload(:SimulatedTargetInterface, 'openc3/interfaces/simulated_target_interface.rb')
27
28
  autoload(:StreamInterface, 'openc3/interfaces/stream_interface.rb')
@@ -53,10 +53,19 @@ module OpenC3
53
53
 
54
54
  USER_AGENT = 'OpenC3 / v7 (ruby/openc3/lib/io/json_api_object)'.freeze
55
55
 
56
+ # Time limit for the first response byte from the server. This must be an
57
+ # explicit number rather than nil, see connect() for why. Requests can
58
+ # legitimately block for a long time, e.g. cmd() with a large timeout waiting
59
+ # on an interface ack, so this is effectively "wait forever" while still
60
+ # bounded so a wedged connection eventually releases the thread.
61
+ DEFAULT_READ_TIMEOUT_S = 86400
62
+
56
63
  # @param url [String] The url of openc3-cosmos-cmd-tlm-api http://openc3-cosmos-cmd-tlm-api:2901
57
64
  # @param timeout [Float] The time to wait before disconnecting 1.0
58
65
  # @param authentication [OpenC3Authentication] The authentication object if nill initialize will generate
59
- def initialize(url:, timeout: 1.0, authentication: nil)
66
+ # @param read_timeout [Float] The time to wait for the first response byte from the server.
67
+ # Defaults to the OPENC3_API_READ_TIMEOUT environment variable or DEFAULT_READ_TIMEOUT_S.
68
+ def initialize(url:, timeout: 1.0, authentication: nil, read_timeout: nil)
60
69
  @http = nil
61
70
  @mutex = Mutex.new
62
71
  @request_data = ""
@@ -65,6 +74,7 @@ module OpenC3
65
74
  @log = [nil, nil, nil]
66
75
  @authentication = authentication.nil? ? generate_auth() : authentication
67
76
  @timeout = timeout
77
+ @read_timeout = (read_timeout || ENV.fetch('OPENC3_API_READ_TIMEOUT', DEFAULT_READ_TIMEOUT_S)).to_f
68
78
  @shutdown = false
69
79
  # JsonDRb.debug = true # Enable for debugging
70
80
  end
@@ -120,7 +130,11 @@ module OpenC3
120
130
  # :open_timeout - time limit for just the connection phase (e.g. handshake) (Integer in seconds)
121
131
  # :read_timeout - time limit for the first response byte received from the server (Integer in seconds)
122
132
  # :write_timeout - time limit for the client to send the request to the server (Integer in seconds)
123
- @http = Faraday.new(request: { open_timeout: @timeout.to_i, read_timeout: nil }) do |f|
133
+ # NOTE: read_timeout must be an explicit number. Faraday only assigns it to Net::HTTP
134
+ # when the value is truthy (see Faraday::Adapter::NetHttp#configure_request), so passing
135
+ # nil does NOT mean "wait forever", it silently leaves Net::HTTP's 60s default in place.
136
+ # That 60s then caps every request regardless of any longer application level timeout.
137
+ @http = Faraday.new(request: { open_timeout: @timeout.to_i, read_timeout: @read_timeout }) do |f|
124
138
  f.adapter :net_http # adds the adapter to the connection, defaults to `Faraday.default_adapter`
125
139
  end
126
140
  rescue => e
@@ -212,7 +226,9 @@ module OpenC3
212
226
  @response_data = resp.body
213
227
  return resp
214
228
  rescue Faraday::ConnectionFailed, Errno::ECONNRESET, Errno::EPIPE, IOError => e
215
- # Connection errors are retryable - reconnect and try again
229
+ # Retryable. Nothing was successfully sent, so replaying is safe. This includes
230
+ # Net::OpenTimeout, which Faraday maps to ConnectionFailed and which covers a
231
+ # service that is still starting up.
216
232
  retry_count += 1
217
233
  @log[2] = "#{method} Exception: #{e.class}, #{e.message}, #{e.backtrace}"
218
234
  if retry_count <= RETRY_COUNT
@@ -225,6 +241,11 @@ module OpenC3
225
241
  raise error
226
242
  end
227
243
  rescue StandardError => e
244
+ # Everything else, including Faraday::TimeoutError from a read timeout, is NOT
245
+ # retryable. A read timeout means the request was fully sent and the server may
246
+ # have already acted on it. Many endpoints are not idempotent (create_activity,
247
+ # script_run, cmd), so a retry risks duplicating the operation. It would also blow
248
+ # past the caller's deadline by RETRY_COUNT times.
228
249
  @log[2] = "#{method} Exception: #{e.class}, #{e.message}, #{e.backtrace}"
229
250
  disconnect()
230
251
  error = "Api Exception: #{@log[0]} ::: #{@log[1]} ::: #{@log[2]}"
@@ -38,8 +38,9 @@ module OpenC3
38
38
  # @param url [String] The url of openc3-cosmos-cmd-tlm-api http://openc3-cosmos-cmd-tlm-api:2901
39
39
  # @param timeout [Float] The time to wait before disconnecting 1.0
40
40
  # @param authentication [OpenC3Authentication] The authentication object if nill initialize will generate
41
- def initialize(url:, timeout: 1.0, authentication: nil)
42
- super(url: url, timeout: timeout, authentication: authentication)
41
+ # @param read_timeout [Float] The time to wait for the first response byte from the server
42
+ def initialize(url:, timeout: 1.0, authentication: nil, read_timeout: nil)
43
+ super(url: url, timeout: timeout, authentication: authentication, read_timeout: read_timeout)
43
44
  @uri = URI("#{url}/openc3-api/api")
44
45
  end
45
46
 
@@ -63,7 +63,10 @@ module OpenC3
63
63
  CYCLE_TIME_INTERVAL = 10
64
64
 
65
65
  # Delay in seconds before trimming Redis streams
66
- CLEANUP_DELAY = 60
66
+ # This is the primary trimmer of the TELEMETRY__ and COMMAND__ streams. Writers
67
+ # also apply a safety MINID cap (see Topic.stream_safety_minid) which depends on this
68
+ # value, so it is defined in Topic.
69
+ CLEANUP_DELAY = Topic::LOG_CLEANUP_DELAY_SECONDS
67
70
 
68
71
  # Mutex protecting class variables
69
72
  @@mutex = Mutex.new
@@ -77,6 +80,14 @@ module OpenC3
77
80
  # Sleeper used to delay cycle thread
78
81
  @@cycle_sleeper = nil
79
82
 
83
+ # Returns the Redis db_shard holding a target stream such as
84
+ # SCOPE__TELEMETRY__{TARGET}__PACKET, or 0 if no target can be determined
85
+ def self.db_shard_for_topic(redis_topic)
86
+ target_match = redis_topic.match(/\{([^{}]+)\}/)
87
+ return 0 unless target_match
88
+ Store.db_shard_for_target(target_match[1], scope: redis_topic.split('__')[0])
89
+ end
90
+
80
91
  # @param remote_log_directory [String] The path to store the log files
81
92
  # @param logging_enabled [Boolean] Whether to start with logging enabled
82
93
  # @param cycle_time [Integer] The amount of time in seconds before creating
@@ -245,9 +256,7 @@ module OpenC3
245
256
  # Now that the file is in S3, trim the Redis stream up until the previous file.
246
257
  # This keeps one minute of data in Redis
247
258
  instance.cleanup_offsets[index].each do |redis_topic, cleanup_offset|
248
- target_match = redis_topic.match(/__\{?([^}_]+)\}?__/)
249
- db_shard = target_match ? Store.db_shard_for_target(target_match[1]) : 0
250
- Topic.trim_topic(redis_topic, cleanup_offset, db_shard: db_shard)
259
+ Topic.trim_topic(redis_topic, cleanup_offset, db_shard: LogWriter.db_shard_for_topic(redis_topic))
251
260
  end
252
261
  indexes_to_clear << index
253
262
  end