odoshi 0.4.1 → 0.4.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: 8640e5e7840c3cac37ffedf58f4cad896ff602e72a61e98d2ee4b75f9b36fead
4
- data.tar.gz: 2a49762d91363eb4867e6b7e61dce1784309e381f03b266b0fe9d713f12fc7d7
3
+ metadata.gz: 210218ceb1b9b969a90c2c7240a2d3265c7ce0850f06b50e5d7766bbc162483d
4
+ data.tar.gz: bc952939508e837da4999c95438cc05da4ef1dfa48f7141614571b89833e0cc0
5
5
  SHA512:
6
- metadata.gz: 0d83801180e7dbd89233e37c877d78a9ee057b16093a0203063a08e0be57b2cbcd3de6f5c98dce4ad0898db0e7cbc92a94edfad2dc7f5c53ba71d7bdd3b7cbd6
7
- data.tar.gz: f6f51e4b02af83310287321aee2668a6ca8d735876827806c9149fe595538174500811f9d53b12a7ea184421a382be6f2a614efe277211caa532f88c4211ae2c
6
+ metadata.gz: 72db2214ebcab14f74e0499e8246e38cc9f552278920a715309123153bd602f6006e57995f53ed2b335754c12335d8a2d05eb42e22156a4b68a9b0c625ec8720
7
+ data.tar.gz: 0b25f4206c6063a36482f626c787a92d88a44bc8ab03936eeb8b59f3e3bb2cde65f7b4104d79317655a2430c529c1f4858bdaca483930d5e6c26c469e08c5111
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.2 — 2026-09-17
4
+
5
+ - **A dead child's last heartbeat can no longer vouch for its replacement** (#18). The
6
+ predecessor's final line could still be sitting in its socket buffer when the successor
7
+ spawned; processed after the record was cleared, it stamped the new child as
8
+ active-and-healthy on the corpse's word — masking a replacement that was failing to boot
9
+ until freshness aging caught up (~3 health intervals). Connections now carry a monotonic
10
+ id and each child records the high-water mark at spawn, so beats from connections that
11
+ predate the current occupant are ignored. Every restart is a new process and therefore a
12
+ new connection, so a live child's own beats always pass. No wire change: §5 is untouched
13
+ and the fix is entirely supervisor-side.
14
+
3
15
  ## v0.4.1 — 2026-09-17
4
16
 
5
17
  - **Typo'd lifecycle options now fail loudly.** Adapter options pass through by design, so
@@ -19,10 +19,20 @@ module Odoshi
19
19
 
20
20
  attr_reader :path, :token
21
21
 
22
+ # Highest connection id issued so far: connections at or below a child's
23
+ # recorded floor predate it (issue #18).
24
+ def conn_seq = @seq_mutex.synchronize { @conn_seq }
25
+
22
26
  def initialize(path:, on_heartbeat:, on_control:)
23
27
  @path, @on_heartbeat, @on_control = path, on_heartbeat, on_control
24
28
  @token = SecureRandom.hex(16)
25
29
  @conns = []
30
+ # Monotonic per-connection id. A restarted child is a NEW process and
31
+ # must open a NEW connection, so the supervisor can tell a successor's
32
+ # beats from its dead predecessor's buffered ones (issue #18) without
33
+ # touching the frozen §5 wire format.
34
+ @conn_seq = 0
35
+ @seq_mutex = Mutex.new
26
36
  end
27
37
 
28
38
  # Binds, chmods, and exports ODOSHI_SOCK / ODOSHI_TOKEN so children
@@ -48,7 +58,8 @@ module Odoshi
48
58
  close_quietly(conn)
49
59
  next
50
60
  end
51
- @conns << { conn: conn, thread: Thread.new { serve(conn) } }
61
+ seq = @seq_mutex.synchronize { @conn_seq += 1 }
62
+ @conns << { conn: conn, seq: seq, thread: Thread.new { serve(conn, seq) } }
52
63
  rescue IOError, SystemCallError
53
64
  break # server closed during shutdown
54
65
  end
@@ -70,7 +81,7 @@ module Odoshi
70
81
 
71
82
  private
72
83
 
73
- def serve(conn)
84
+ def serve(conn, seq)
74
85
  loop do
75
86
  line = conn.gets("\n", MAX_LINE)
76
87
  break if line.nil?
@@ -81,7 +92,7 @@ module Odoshi
81
92
  break if line.nil?
82
93
  next
83
94
  end
84
- handle_line(line)
95
+ handle_line(line, seq)
85
96
  end
86
97
  rescue IOError, SystemCallError
87
98
  nil
@@ -92,7 +103,7 @@ module Odoshi
92
103
  # §5: token, cmd, id, and state are JSON strings; anything else in those
93
104
  # fields is malformed and silently dropped — same as a bad token. A bad
94
105
  # line must never take the connection (or the supervisor) down.
95
- def handle_line(line)
106
+ def handle_line(line, seq)
96
107
  msg = begin
97
108
  JSON.parse(line)
98
109
  rescue StandardError
@@ -102,7 +113,7 @@ module Odoshi
102
113
  if msg.key?("cmd")
103
114
  @on_control.call(msg) if msg["cmd"].is_a?(String)
104
115
  elsif msg["id"].is_a?(String) && msg["state"].is_a?(String)
105
- @on_heartbeat.call(msg)
116
+ @on_heartbeat.call(msg, seq)
106
117
  end
107
118
  end
108
119
 
@@ -16,6 +16,7 @@ module Odoshi
16
16
  @stopping = false
17
17
  @stop_requested = false
18
18
  @heartbeats = {} # id => { at: monotonic ts of last heartbeat, state: reported state }
19
+ @hb_floor = {} # id => connection seq at spawn; older connections are the corpse's (#18)
19
20
  return unless socket_path
20
21
  @socket = SocketServer.new(
21
22
  path: socket_path,
@@ -23,9 +24,18 @@ module Odoshi
23
24
  # dropped at intake: ghost ids must not grow the table unboundedly
24
25
  # (issue #16/#27). Subtree-grandchild routing is the open flat-id
25
26
  # question — until it's decided, their beats are dropped, not hoarded.
26
- on_heartbeat: lambda { |msg|
27
+ # A beat counts only if it comes from a connection opened AFTER the
28
+ # child currently holding that id was spawned. The predecessor's
29
+ # last line can still be sitting in its socket buffer when the
30
+ # replacement starts; accepting it would vouch for a successor that
31
+ # may be failing to boot (#18). Every restart is a new process and
32
+ # therefore a new connection, so a live child's own beats always
33
+ # pass. Ghost ids are still dropped at intake (#16).
34
+ on_heartbeat: lambda { |msg, seq|
27
35
  id = msg["id"].to_sym
28
- @heartbeats[id] = { at: mono_now, state: msg["state"] } if @children.any? { |c| c.id == id }
36
+ next unless @children.any? { |c| c.id == id }
37
+ next if seq <= @hb_floor[id].to_i
38
+ @heartbeats[id] = { at: mono_now, state: msg["state"] }
29
39
  },
30
40
  on_control: ->(msg) { @queue << { type: :control, cmd: msg["cmd"], id: msg["id"].to_s.to_sym } }
31
41
  )
@@ -109,7 +119,12 @@ module Odoshi
109
119
  end
110
120
 
111
121
  def spawn_and_link(spec)
112
- @heartbeats.delete(spec.id) # a replaced child's heartbeats must not vouch for its successor
122
+ # A replaced child's heartbeats must not vouch for its successor:
123
+ # clear the record AND raise the connection floor, so beats still
124
+ # buffered on the corpse's connection are ignored rather than racing
125
+ # the delete (#18).
126
+ @heartbeats.delete(spec.id)
127
+ @hb_floor[spec.id] = @socket&.conn_seq || 0
113
128
  adapter = Adapter.lookup(spec.adapter).new
114
129
  handle = adapter.spawn(spec)
115
130
  prev = @live[spec.id] || {}
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Odoshi
3
- VERSION = "0.4.1"
3
+ VERSION = "0.4.2"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: odoshi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - timimsms