rsmp 0.48.4 → 0.51.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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/Gemfile.lock +19 -20
  4. data/README.md +27 -3
  5. data/Rakefile +3 -1
  6. data/documentation/cli.md +79 -4
  7. data/documentation/collecting_message.md +90 -170
  8. data/documentation/configuration.md +8 -0
  9. data/documentation/message_distribution.md +17 -3
  10. data/documentation/tasks.md +65 -117
  11. data/lib/rsmp/cli.rb +93 -5
  12. data/lib/rsmp/collect/collector.rb +117 -134
  13. data/lib/rsmp/collect/distributor.rb +16 -3
  14. data/lib/rsmp/collect/queue.rb +14 -6
  15. data/lib/rsmp/collect/receiver.rb +1 -1
  16. data/lib/rsmp/collect/state_collector.rb +11 -2
  17. data/lib/rsmp/collection.rb +12 -0
  18. data/lib/rsmp/completion.rb +49 -0
  19. data/lib/rsmp/component/component_base.rb +3 -3
  20. data/lib/rsmp/convert/export/json_schema/index.rb +8 -4
  21. data/lib/rsmp/convert/import/yaml.rb +12 -8
  22. data/lib/rsmp/delivery.rb +18 -0
  23. data/lib/rsmp/event.rb +19 -0
  24. data/lib/rsmp/event_source.rb +36 -0
  25. data/lib/rsmp/exchange.rb +10 -0
  26. data/lib/rsmp/failure.rb +23 -0
  27. data/lib/rsmp/helpers/error.rb +14 -23
  28. data/lib/rsmp/message.rb +10 -9
  29. data/lib/rsmp/node/node.rb +3 -21
  30. data/lib/rsmp/node/site/connections.rb +4 -9
  31. data/lib/rsmp/node/site/site.rb +33 -17
  32. data/lib/rsmp/node/supervisor/modules/configuration.rb +24 -11
  33. data/lib/rsmp/node/supervisor/modules/connection.rb +19 -5
  34. data/lib/rsmp/node/supervisor/modules/sites.rb +16 -5
  35. data/lib/rsmp/node/supervisor/supervisor.rb +13 -14
  36. data/lib/rsmp/node/task.rb +59 -53
  37. data/lib/rsmp/proxy/modules/acknowledgements.rb +22 -9
  38. data/lib/rsmp/proxy/modules/lifecycle.rb +134 -0
  39. data/lib/rsmp/proxy/modules/receive.rb +109 -66
  40. data/lib/rsmp/proxy/modules/send.rb +109 -51
  41. data/lib/rsmp/proxy/modules/state.rb +15 -5
  42. data/lib/rsmp/proxy/modules/tasks.rb +96 -70
  43. data/lib/rsmp/proxy/modules/versions.rb +69 -19
  44. data/lib/rsmp/proxy/modules/watchdogs.rb +15 -2
  45. data/lib/rsmp/proxy/proxy.rb +7 -69
  46. data/lib/rsmp/proxy/site/connection.rb +27 -22
  47. data/lib/rsmp/proxy/site/modules/aggregated_status.rb +18 -8
  48. data/lib/rsmp/proxy/site/modules/alarms.rb +45 -38
  49. data/lib/rsmp/proxy/site/modules/commands.rb +18 -8
  50. data/lib/rsmp/proxy/site/modules/status.rb +44 -19
  51. data/lib/rsmp/proxy/site/site_proxy.rb +12 -10
  52. data/lib/rsmp/proxy/site/sxl_selection.rb +7 -2
  53. data/lib/rsmp/proxy/supervisor/modules/aggregated_status.rb +1 -1
  54. data/lib/rsmp/proxy/supervisor/modules/alarms.rb +1 -1
  55. data/lib/rsmp/proxy/supervisor/modules/commands.rb +1 -1
  56. data/lib/rsmp/proxy/supervisor/modules/message_buffer.rb +8 -9
  57. data/lib/rsmp/proxy/supervisor/modules/status.rb +1 -1
  58. data/lib/rsmp/proxy/supervisor/modules/status_updates.rb +1 -1
  59. data/lib/rsmp/proxy/supervisor/supervisor_proxy.rb +36 -31
  60. data/lib/rsmp/result.rb +95 -0
  61. data/lib/rsmp/schema/validation.rb +5 -3
  62. data/lib/rsmp/schema/version.rb +27 -0
  63. data/lib/rsmp/schema.rb +1 -0
  64. data/lib/rsmp/sxl/interface.rb +8 -1
  65. data/lib/rsmp/sxl/processing/catalogue.rb +61 -0
  66. data/lib/rsmp/sxl/processing/conflict_checker.rb +30 -0
  67. data/lib/rsmp/sxl/processing/dependency_graph.rb +37 -0
  68. data/lib/rsmp/sxl/processing/document.rb +158 -0
  69. data/lib/rsmp/sxl/processing/error.rb +8 -0
  70. data/lib/rsmp/sxl/processing/manifest.rb +144 -0
  71. data/lib/rsmp/sxl/processing/natural_sort.rb +22 -0
  72. data/lib/rsmp/sxl/processing/resolver.rb +94 -0
  73. data/lib/rsmp/sxl/processing/version_requirement.rb +86 -0
  74. data/lib/rsmp/sxl/processing.rb +18 -0
  75. data/lib/rsmp/sxl/supervisor_interface.rb +8 -1
  76. data/lib/rsmp/termination.rb +10 -0
  77. data/lib/rsmp/tlc/modules/plans.rb +0 -2
  78. data/lib/rsmp/tlc/modules/system.rb +2 -2
  79. data/lib/rsmp/tlc/proxy/control.rb +50 -16
  80. data/lib/rsmp/tlc/proxy/detectors.rb +15 -4
  81. data/lib/rsmp/tlc/proxy/io.rb +46 -31
  82. data/lib/rsmp/tlc/proxy/plans.rb +36 -19
  83. data/lib/rsmp/tlc/proxy/plans_bang.rb +44 -0
  84. data/lib/rsmp/tlc/proxy/status.rb +47 -23
  85. data/lib/rsmp/tlc/proxy/system.rb +16 -4
  86. data/lib/rsmp/tlc/supervisor_interface.rb +7 -1
  87. data/lib/rsmp/tlc/traffic_controller_site.rb +6 -9
  88. data/lib/rsmp/validation.rb +22 -0
  89. data/lib/rsmp/version.rb +1 -1
  90. data/lib/rsmp.rb +19 -4
  91. data/rsmp.gemspec +1 -1
  92. metadata +26 -4
  93. data/lib/rsmp/collect/collector/status.rb +0 -34
@@ -1,79 +1,137 @@
1
1
  module RSMP
2
2
  class Proxy
3
3
  module Modules
4
- # Message sending functionality
5
- # Handles sending messages, validation, and buffering
4
+ # Message sending functionality. Expected operational failures are returned
5
+ # as Result::Failure; unexpected implementation errors are not rescued.
6
6
  module Send
7
- def handle_send_schema_error(message, error)
8
- schemas_string = error.schemas.map { |schema| "#{schema.first}: #{schema.last}" }.join(', ')
9
- str = "Could not send #{message.type} because schema validation failed (#{schemas_string}): #{error.message}"
10
- log str, message: message, level: :error
11
- distribute_error error.exception("#{str} #{message.json}")
12
- end
13
-
14
7
  def send_message(message, reason = nil, validate: true, force: false, buffer: true)
15
- with_send_ready(message, force: force, buffer: buffer) do
16
- write_message(message, validate: validate)
17
- expect_acknowledgement message
18
- distribute message
19
- log_send message, reason
20
- end
21
- rescue NotReady, IOError => e
22
- raise e unless buffer
8
+ return buffer_or_fail(message, buffer: buffer) unless force || connected?
23
9
 
24
- buffer_message message, e
25
- rescue SchemaError, RSMP::Schema::Error => e
26
- handle_send_schema_error(message, e)
27
- end
10
+ written = write_message(message, validate: validate)
11
+ return recover_write_failure(message, written, buffer: buffer) if written.failure?
28
12
 
29
- def with_send_ready(message, force:, buffer:)
30
- return yield if force || connected?
13
+ expect_acknowledgement(message)
14
+ distribute(message)
15
+ log_send(message, reason)
16
+ Result.success(Delivery.new(message: message, state: :sent))
17
+ end
31
18
 
32
- error = NotReady.new
33
- raise error unless buffer
19
+ def send_message!(...)
20
+ send_message(...).value!
21
+ end
34
22
 
35
- buffer_message(message, error)
23
+ # Messages constructed by RSMP itself should never fail local schema
24
+ # validation. Surface that as an implementation defect while preserving
25
+ # connection and transport failures as ordinary Result values.
26
+ def send_generated_message(...)
27
+ result = send_message(...)
28
+ result.value! if result.failure&.source == :local
29
+ result
36
30
  end
37
31
 
38
32
  def write_message(message, validate:)
39
- raise IOError unless @protocol
33
+ return disconnected_send_result(message) unless @protocol
34
+
35
+ prepared = prepare_message(message, validate: validate)
36
+ return prepared if prepared.failure?
40
37
 
38
+ write_protocol(message)
39
+ end
40
+
41
+ def prepare_message(message, validate:)
41
42
  message.direction = :out
42
43
  message.encode_for(schemas) unless validate == false
43
44
  message.generate_json
44
- message.validate schemas unless validate == false
45
- @protocol.write_lines message.json
45
+ unless validate == false
46
+ validation = message.validate(schemas)
47
+ return invalid_outbound_result(message, validation.message, validation: validation) if validation.invalid?
48
+ end
49
+ Result.success(message)
50
+ rescue RSMP::Schema::UnknownMessageCodeError => e
51
+ invalid_outbound_result(message, e.message, cause: e)
52
+ end
53
+
54
+ def write_protocol(message)
55
+ @protocol.write_lines(message.json)
56
+ Result.success(message)
57
+ rescue IOError, SystemCallError => e
58
+ Result.failure(
59
+ :disconnected,
60
+ message: "Could not send #{message.type}: #{e.message}",
61
+ source: :transport,
62
+ context: { message: message, session_id: session_id },
63
+ cause: e
64
+ )
65
+ end
66
+
67
+ def recover_write_failure(message, result, buffer:)
68
+ return result unless result.failure.code == :disconnected
69
+
70
+ buffered = buffer_message(message) if buffer
71
+ return Result.success(Delivery.new(message: buffered, state: :buffered)) if buffered
72
+
73
+ result
46
74
  end
47
75
 
48
- def buffer_message(message, error = nil)
49
- str = "Cannot send #{message.type} because the connection is closed."
50
- log str, message: message, level: :error
51
- raise error if error
76
+ def buffer_or_fail(message, buffer:)
77
+ buffered = buffer_message(message) if buffer
78
+ return Result.success(Delivery.new(message: buffered, state: :buffered)) if buffered
79
+
80
+ Result.failure(
81
+ :not_ready,
82
+ message: "Cannot send #{message.type}: connection is #{@state}",
83
+ source: :connection,
84
+ context: { message: message, state: @state, session_id: session_id }
85
+ )
86
+ end
87
+
88
+ # Base proxies do not buffer. SupervisorProxy's MessageBuffer override
89
+ # returns a cloned queued message when its explicit policy accepts it.
90
+ def buffer_message(message)
91
+ log "Discarded #{message.type}; connection is #{@state}", message: message, level: :warning
92
+ nil
93
+ end
94
+
95
+ def disconnected_send_result(message)
96
+ Result.failure(
97
+ :disconnected,
98
+ message: "Cannot send #{message.type}: connection transport is closed",
99
+ source: :transport,
100
+ context: { message: message, session_id: session_id }
101
+ )
102
+ end
103
+
104
+ def invalid_outbound_result(message, detail, validation: nil, cause: nil)
105
+ text = "Could not send #{message.type}: #{detail}"
106
+ log(text, message: message, level: :error)
107
+ Result.failure(
108
+ :invalid_outbound_message,
109
+ message: text,
110
+ source: :local,
111
+ context: { message: message, validation: validation }.compact,
112
+ cause: cause
113
+ )
52
114
  end
53
115
 
54
116
  def log_send(message, reason = nil)
55
- str = if reason
56
- "Sent #{message.type} #{reason}"
57
- else
58
- "Sent #{message.type}"
59
- end
60
-
61
- if message.type == 'MessageNotAck'
62
- log str, message: message, level: :warning
63
- else
64
- log str, message: message, level: :log
65
- end
117
+ text = reason ? "Sent #{message.type} #{reason}" : "Sent #{message.type}"
118
+ level = message.type == 'MessageNotAck' ? :warning : :log
119
+ log(text, message: message, level: level)
66
120
  end
67
121
 
68
122
  def send_message_and_collect(message, collector, validate: true)
69
- task = @task.async do |t|
70
- t.annotate 'send_message_and_collect'
71
- collector.collect
72
- collector
123
+ collector.start
124
+ delivery = send_message(message, validate: validate, buffer: false)
125
+ collector.fail(delivery.failure) if delivery.failure?
126
+ collector.wait.map do |collection|
127
+ Exchange.new(request: message, collection: collection)
73
128
  end
74
- send_message message, validate: validate
75
- collector = task.wait
76
- { sent: message, collector: collector }
129
+ ensure
130
+ collector.stop
131
+ end
132
+
133
+ def send_message_and_collect!(...)
134
+ send_message_and_collect(...).value!
77
135
  end
78
136
 
79
137
  def apply_nts_message_attributes(message)
@@ -6,14 +6,24 @@ module RSMP
6
6
  module State
7
7
  def wait_for_state(state, timeout:)
8
8
  states = [state].flatten
9
- return true if states.include?(@state)
9
+ return Result.success(@state) if states.include?(@state)
10
10
 
11
- wait_for_condition(@state_condition, timeout: timeout) do
11
+ result = wait_for_condition(@state_condition, timeout: timeout) do
12
12
  states.include?(@state)
13
13
  end
14
- true
15
- rescue RSMP::TimeoutError
16
- raise RSMP::TimeoutError, "Did not reach state #{state} within #{timeout}s"
14
+ return Result.success(@state) if result.success?
15
+
16
+ Result.failure(
17
+ :timeout,
18
+ message: "Did not reach state #{state} within #{timeout}s",
19
+ source: :timeout,
20
+ context: { expected: states, actual: @state },
21
+ cause: result.failure.cause
22
+ )
23
+ end
24
+
25
+ def wait_for_state!(...)
26
+ wait_for_state(...).value!
17
27
  end
18
28
 
19
29
  def handshake_complete
@@ -1,103 +1,129 @@
1
1
  module RSMP
2
2
  class Proxy
3
3
  module Modules
4
- # Reader and timer task management
5
- # Handles async tasks for reading from socket and running periodic timers
4
+ # Reader and timer tasks are siblings in one supervised session barrier.
6
5
  module Tasks
7
- # run an async task that reads from @socket
6
+ def session_active?(id = @session_id)
7
+ id == @session_id && !@session_closed
8
+ end
9
+
10
+ def session_tasks
11
+ @session_tasks ||= Async::Barrier.new(parent: @task)
12
+ end
13
+
8
14
  def start_reader
9
- @reader = @task.async do |task|
10
- task.annotate 'reader'
11
- run_reader
15
+ id = @session_id
16
+ @reader = session_tasks.async do |task|
17
+ task.annotate "reader session #{id}"
18
+ run_reader(id)
12
19
  end
13
20
  end
14
21
 
15
- def run_reader
22
+ def run_reader(id)
16
23
  @stream ||= IO::Stream::Buffered.new(@socket)
17
- @protocol ||= RSMP::Protocol.new(@stream) # rsmp messages are json terminated with a form-feed
18
- loop do
19
- read_line
24
+ @protocol ||= RSMP::Protocol.new(@stream)
25
+ while session_active?(id)
26
+ line = read_protocol_line
27
+ return line if line.failure?
28
+
29
+ process_received_line(line.value)
20
30
  end
21
- rescue Restart
22
- log 'Closing connection', level: :warning
23
- raise
24
- rescue EOFError, Async::Stop
25
- log 'Connection closed', level: :warning
26
- rescue IOError => e
27
- log "IOError: #{e}", level: :debug
28
- rescue Errno::ECONNRESET
29
- log 'Connection reset by peer', level: :warning
30
- rescue Errno::EPIPE
31
- log 'Broken pipe', level: :warning
32
- rescue StandardError => e
33
- distribute_error e, level: :internal
31
+ Result.success(:closed)
34
32
  end
35
33
 
36
- def read_line
34
+ def read_protocol_line
37
35
  json = @protocol.read_line
36
+ raise EOFError, 'Connection closed by peer' unless json
37
+
38
+ Result.success(json)
39
+ rescue EOFError => e
40
+ connection_read_failure(:peer_closed, 'Connection closed by peer', e)
41
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE => e
42
+ connection_read_failure(:transport_failure, e.message, e)
43
+ end
44
+
45
+ def connection_read_failure(reason, text, error)
46
+ log(text, level: :warning)
47
+ Result.failure(
48
+ :disconnected,
49
+ message: text,
50
+ source: reason == :peer_closed ? :peer : :transport,
51
+ context: { reason: reason, session_id: @session_id },
52
+ cause: error
53
+ )
54
+ end
55
+
56
+ # Wait for the reader while observing every sibling. A failed timer task
57
+ # raises here with its original exception and backtrace.
58
+ def wait_for_session
59
+ reader = @reader
60
+ observed = session_tasks.wait do |finished|
61
+ result = finished.wait
62
+ break result if finished.equal?(reader)
63
+ break result if result.is_a?(Result::Failure)
64
+ next if finished.cancelled? || !session_active?
65
+
66
+ raise "#{finished.annotation} ended while its connection session was active"
67
+ end
68
+ observed.is_a?(Result::Success) || observed.is_a?(Result::Failure) ? observed : Result.success(:closed)
69
+ ensure
70
+ @session_tasks.cancel if @session_tasks && !@session_tasks.empty?
71
+ @session_tasks = nil
72
+ end
73
+
74
+ def process_received_line(json)
38
75
  beginning = Time.now
39
- message = process_packet json
76
+ result = process_packet(json)
77
+ log_processing_statistics(result, beginning)
78
+ result
79
+ end
80
+
81
+ def log_processing_statistics(result, beginning)
82
+ message = result.success? ? result.value : result.failure.context[:message]
40
83
  duration = Time.now - beginning
41
- ms = (duration * 1000).round(4)
42
- per_second = if duration.positive?
43
- (1.0 / duration).round
44
- else
45
- Float::INFINITY
46
- end
47
- if message
48
- type = message.type
49
- m_id = Logger.shorten_message_id(message.m_id)
50
- else
51
- type = 'Unknown'
52
- m_id = nil
53
- end
54
- str = [type, m_id, "processed in #{ms}ms, #{per_second}req/s"].compact.join(' ')
55
- log str, level: :statistics
84
+ type = message.respond_to?(:type) ? message.type : 'Unknown'
85
+ m_id = message.respond_to?(:m_id) ? Logger.shorten_message_id(message.m_id) : nil
86
+ log([type, m_id, processing_speed(duration)].compact.join(' '), level: :statistics)
87
+ end
88
+
89
+ def processing_speed(duration)
90
+ milliseconds = (duration * 1000).round(4)
91
+ per_second = duration.positive? ? (1.0 / duration).round : Float::INFINITY
92
+ "processed in #{milliseconds}ms, #{per_second}req/s"
56
93
  end
57
94
 
58
95
  def start_timer
59
- return if @timer
96
+ return if @timer&.running?
60
97
 
61
- name = 'timer'
98
+ id = @session_id
62
99
  interval = @site_settings['intervals']['timer'] || 1
63
- log "Starting #{name} with interval #{interval} seconds", level: :debug
100
+ log "Starting timer with interval #{interval} seconds", level: :debug
64
101
  @latest_watchdog_received = Clock.now
65
- @timer = @task.async do |task|
66
- task.annotate 'timer'
67
- run_timer task, interval
102
+ @timer = session_tasks.async do |task|
103
+ task.annotate "timer session #{id}"
104
+ run_timer(task, interval, id)
68
105
  end
69
106
  end
70
107
 
71
- def run_timer(task, interval)
108
+ def run_timer(task, interval, id)
72
109
  next_time = Time.now.to_f
73
- loop do
74
- begin
75
- now = Clock.now
76
- timer(now)
77
- rescue RSMP::Schema::Error => e
78
- log "Timer: Schema error: #{e}", level: :warning
79
- rescue EOFError => e
80
- log "Timer: Connection closed: #{e}", level: :warning
81
- rescue IOError
82
- log 'Timer: IOError', level: :warning
83
- rescue Errno::ECONNRESET
84
- log 'Timer: Connection reset by peer', level: :warning
85
- rescue Errno::EPIPE
86
- log 'Timer: Broken pipe', level: :warning
87
- rescue StandardError => e
88
- distribute_error e, level: :internal
89
- end
90
- ensure
110
+ while session_active?(id)
111
+ result = timer(Clock.now)
112
+ return result if result.failure?
113
+
91
114
  next_time += interval
92
- duration = next_time - Time.now.to_f
93
- task.sleep duration
115
+ task.sleep([next_time - Time.now.to_f, 0].max) if session_active?(id)
94
116
  end
117
+ Result.success(:closed)
95
118
  end
96
119
 
97
120
  def timer(now)
98
- watchdog_send_timer now
99
- check_ack_timeout now
100
- check_watchdog_timeout now
121
+ watchdog_send_timer(now)
122
+ acknowledgement = check_ack_timeout(now)
123
+ return acknowledgement if acknowledgement.failure?
124
+
125
+ check_watchdog_timeout(now)
126
+ Result.success
101
127
  end
102
128
  end
103
129
  end
@@ -8,13 +8,23 @@ module RSMP
8
8
  version = @site_settings['core_version']
9
9
  if version == 'latest'
10
10
  [RSMP::Schema.latest_core_version]
11
- elsif version
12
- [version]
13
- else
11
+ elsif version.nil? || version == 'all'
14
12
  RSMP::Schema.core_versions
13
+ else
14
+ [RSMP::Schema.normalize_core_version(version)].compact
15
15
  end
16
16
  end
17
17
 
18
+ # Version strings to advertise before a Core version has been selected.
19
+ # Include known legacy spellings so peers using exact string matching
20
+ # can find a common pre-3.3 version.
21
+ def advertised_core_versions
22
+ versions = core_versions.flat_map { |version| wire_core_version_aliases(version) }
23
+ configured = @site_settings['core_version']
24
+ versions.unshift configured if RSMP::Schema.normalize_core_version(configured)
25
+ versions.uniq
26
+ end
27
+
18
28
  def core_3_3?
19
29
  core_version && version_meets_requirement?(core_version, '>=3.3.0')
20
30
  end
@@ -29,8 +39,9 @@ module RSMP
29
39
 
30
40
  def sxl_request_items
31
41
  configured_sxls.map do |sxl|
32
- item = { 'name' => sxl['name'], 'version' => sxl['version'].to_s }
33
- prefix = RSMP::Schema.sxl_prefix(sxl['name'], sxl['version'], lenient: true)
42
+ version = RSMP::Schema.sanitize_version(sxl['version'].to_s)
43
+ item = { 'name' => sxl['name'], 'version' => version }
44
+ prefix = RSMP::Schema.sxl_prefix(sxl['name'], version)
34
45
  item['prefix'] = prefix if prefix
35
46
  item
36
47
  end
@@ -38,10 +49,14 @@ module RSMP
38
49
 
39
50
  def check_core_version(message)
40
51
  versions = core_versions
41
- # find versions that both we and the client support
42
- candidates = message.versions & versions
52
+ candidates = message.versions.filter_map do |wire_version|
53
+ normalized = RSMP::Schema.normalize_core_version(wire_version)
54
+ [wire_version, normalized] if normalized && versions.include?(normalized)
55
+ end
43
56
  if candidates.any?
44
- @core_version = candidates.max_by { |v| Gem::Version.new(v) } # pick latest version
57
+ @core_version_string, @core_version = candidates.max_by do |_wire_version, normalized|
58
+ Gem::Version.new(normalized)
59
+ end
45
60
  else
46
61
  reason = "RSMP versions [#{message.versions.join(', ')}] requested, " \
47
62
  "but only [#{versions.join(', ')}] supported."
@@ -66,22 +81,33 @@ module RSMP
66
81
 
67
82
  def send_version_response(site_id, core_versions)
68
83
  if core_3_3?
69
- send_message Version.new({
70
- 'step' => 'Response',
71
- 'RSMP' => [{ 'vers' => core_version }],
72
- 'supervisorId' => site_id,
73
- 'SXLS' => version_response_sxls,
74
- 'receiveAlarms' => @site_settings['receive_alarms'] != false
75
- }), validate: false
84
+ send_generated_message Version.new({
85
+ 'step' => 'Response',
86
+ 'RSMP' => [{ 'vers' => core_version }],
87
+ 'supervisorId' => site_id,
88
+ 'SXLS' => version_response_sxls,
89
+ 'receiveAlarms' => @site_settings['receive_alarms'] != false
90
+ }), validate: false
76
91
  else
77
- send_version_message(site_id, core_versions, step: nil)
92
+ send_legacy_version_response(site_id, core_versions)
78
93
  end
79
94
  end
80
95
 
96
+ def send_legacy_version_response(site_id, core_versions)
97
+ response_versions = normalized_core_versions(core_versions).map do |version|
98
+ normalized = RSMP::Schema.normalize_core_version(version)
99
+ normalized == core_version ? core_version_string : version
100
+ end.uniq
101
+ attributes = version_message_attributes(site_id, response_versions)
102
+ primary = accepted_sxls.first
103
+ attributes['SXL'] = primary['version'].to_s if primary
104
+ send_message Version.new(attributes), validate: false
105
+ end
106
+
81
107
  def send_version_message(site_id, core_versions, step:)
82
108
  attributes = version_message_attributes(site_id, core_versions)
83
109
  attributes.merge!(version_request_attributes) if step == 'Request'
84
- send_message Version.new(attributes), validate: false
110
+ send_generated_message Version.new(attributes), validate: false
85
111
  end
86
112
 
87
113
  def version_message_attributes(site_id, core_versions)
@@ -109,6 +135,15 @@ module RSMP
109
135
  end
110
136
  end
111
137
 
138
+ def wire_core_version_aliases(version)
139
+ normalized = RSMP::Schema.normalize_core_version(version)
140
+ return [version] unless normalized
141
+ return [normalized] if Gem::Version.new(normalized) >= Gem::Version.new('3.3.0')
142
+ return [normalized] unless normalized.end_with?('.0')
143
+
144
+ [normalized, normalized.delete_suffix('.0')]
145
+ end
146
+
112
147
  def site_id_items(site_id)
113
148
  [site_id].flatten.map { |id| { 'sId' => id } }
114
149
  end
@@ -124,13 +159,28 @@ module RSMP
124
159
  accepted_sxls + rejected_sxls
125
160
  end
126
161
 
162
+ def validate_sxl_response!(sxls)
163
+ invalid = sxls.find { |accepted| !valid_sxl_response?(accepted) }
164
+ return unless invalid
165
+
166
+ raise HandshakeError,
167
+ "Invalid SXL version #{invalid['name']} #{invalid['version']}; " \
168
+ 'Core 3.3 requires an exact MAJOR.MINOR.PATCH match'
169
+ end
170
+
171
+ def valid_sxl_response?(accepted)
172
+ requested = sxl_request_items.find { |item| item['name'] == accepted['name'] }
173
+ RSMP::Schema.strict_version?(accepted['version']) && requested &&
174
+ requested['version'] == accepted['version']
175
+ end
176
+
127
177
  def version_acknowledged; end
128
178
 
129
179
  def component_list_acknowledged; end
130
180
 
131
181
  # Use Gem class to check version requirement
132
- # Requirement must be a string like '1.1', '>=1.0.3' or '<2.1.4',
133
- # or list of strings, like ['<=1.4','<1.5']
182
+ # Requirement must be a string like '1.1.0', '>=1.0.3' or '<2.1.4',
183
+ # or list of strings, like ['<=1.4.0','<1.5.0']
134
184
  def self.version_meets_requirement?(version, requirement)
135
185
  Gem::Requirement.new(requirement).satisfied_by?(Gem::Version.new(version))
136
186
  end
@@ -40,7 +40,7 @@ module RSMP
40
40
 
41
41
  def send_watchdog(now = Clock.now)
42
42
  message = RSMP::Watchdog.new({ 'wTs' => clock.to_s })
43
- send_message message
43
+ send_generated_message message
44
44
  @latest_watchdog_send_at = now
45
45
  end
46
46
 
@@ -52,7 +52,20 @@ module RSMP
52
52
 
53
53
  str = "No Watchdog received within #{timeout} seconds"
54
54
  log str, level: :warning
55
- distribute MissingWatchdog.new(str)
55
+ failure = Failure.new(
56
+ code: :missing_watchdog,
57
+ message: str,
58
+ source: :peer,
59
+ context: { session_id: session_id }
60
+ )
61
+ distribute_event(
62
+ Event.new(
63
+ type: :missing_watchdog,
64
+ source: self,
65
+ session_id: session_id,
66
+ failure: failure
67
+ )
68
+ )
56
69
  end
57
70
 
58
71
  def process_watchdog(message)