tina4ruby 3.13.94 → 3.13.96

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +243 -29
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Tina4
6
+ module SessionHandlers
7
+ # A MongoDB command that came back with `ok != 1`, or a transport failure,
8
+ # surfaced as an exception so the handler can tell a real error apart from a
9
+ # genuine miss (an empty firstBatch). Same split RespError makes for RESP.
10
+ class MongoWireError < StandardError; end
11
+
12
+ # Zero-dependency synchronous MongoDB client speaking the wire protocol
13
+ # (OP_MSG, opcode 2013) over a TCP socket, with a minimal BSON codec.
14
+ #
15
+ # WHY IT EXISTS (session_contract.json #6, ADR-0024). Python, PHP and Node
16
+ # have always shipped a raw OP_MSG fallback for the mongodb SESSION backend;
17
+ # Ruby alone hard-required the `mongo` gem and RAISED without it. The same
18
+ # .env therefore worked in three frameworks and failed in the fourth, which
19
+ # breaks the zero-dependency promise ASYMMETRICALLY - worse than breaking it
20
+ # consistently, because nothing about the configuration hints at it. This is
21
+ # the missing fourth transport, ported from the proven Python master
22
+ # (tina4_python/session_handlers/mongodb_handler.py) rather than invented.
23
+ #
24
+ # It is deliberately NOT a general Mongo driver. It implements exactly the
25
+ # four operations the session handler performs - find_one by _id, upsert by
26
+ # _id, delete_one by _id, and delete_many for gc - and it mirrors the SHAPE
27
+ # of the `mongo` gem's Collection for those four so the handler's read,
28
+ # write, destroy and gc bodies are IDENTICAL on both transports. That is not
29
+ # cosmetic: a branch inside each operation is a branch that can be wrong on
30
+ # one path only, and this fallback would then ship untested behind a
31
+ # condition nobody exercises.
32
+ #
33
+ # HARD-WON DETAILS, preserved from the three siblings:
34
+ # * The COMMAND NAME comes FIRST in the document and `$db` LAST. Reversed,
35
+ # the server reads `$db` as the command name and answers CommandNotFound.
36
+ # * BSON is little-endian throughout, string lengths are BYTE counts (not
37
+ # character counts), and a Time is encoded as BSON UTC datetime (0x09,
38
+ # int64 milliseconds) so a document written here is byte-identical in
39
+ # shape to one the gem writes.
40
+ # * An OP_MSG reply can arrive split across TCP segments, so the body is
41
+ # read by its exact declared byte count rather than one recv().
42
+ #
43
+ # NO NETWORK I/O IN A CONSTRUCTOR (ADR-0021, session_contract.json #4): the
44
+ # socket is opened on the FIRST command, inside the log-loud-and-degrade
45
+ # policy, never here.
46
+ class MongoWireClient
47
+ OP_MSG = 2013
48
+
49
+ # BSON element type bytes, named so the codec reads as the spec does.
50
+ TYPE_DOUBLE = 0x01
51
+ TYPE_STRING = 0x02
52
+ TYPE_DOCUMENT = 0x03
53
+ TYPE_ARRAY = 0x04
54
+ TYPE_BINARY = 0x05
55
+ TYPE_OBJECT_ID = 0x07
56
+ TYPE_BOOLEAN = 0x08
57
+ TYPE_DATETIME = 0x09
58
+ TYPE_NULL = 0x0A
59
+ TYPE_INT32 = 0x10
60
+ TYPE_TIMESTAMP = 0x11
61
+ TYPE_INT64 = 0x12
62
+
63
+ INT32_MIN = -2_147_483_648
64
+ INT32_MAX = 2_147_483_647
65
+
66
+ def initialize(host:, port:, database:, collection:, timeout: 10)
67
+ @host = host
68
+ @port = port
69
+ @database = database
70
+ @collection = collection
71
+ @timeout = timeout
72
+ @socket = nil
73
+ @request_id = 0
74
+ end
75
+
76
+ # Documents matching +filter+, capped at one.
77
+ #
78
+ # Returns an ARRAY so the caller can write `find(...).first` exactly as it
79
+ # does against the gem's Collection, which answers a lazy view. One shared
80
+ # call site, two transports.
81
+ def find(filter)
82
+ reply = command("find" => @collection, "filter" => filter, "limit" => 1, "$db" => @database)
83
+ reply.dig("cursor", "firstBatch") || []
84
+ end
85
+
86
+ # Apply +update+ (an operator document such as `{"$set" => {...}}`, or a
87
+ # full replacement) to the single document matching +filter+.
88
+ def update_one(filter, update, upsert: false)
89
+ command(
90
+ "update" => @collection,
91
+ "updates" => [{ "q" => filter, "u" => update, "upsert" => upsert }],
92
+ "$db" => @database
93
+ )
94
+ end
95
+
96
+ def delete_one(filter)
97
+ delete(filter, 1)
98
+ end
99
+
100
+ def delete_many(filter)
101
+ delete(filter, 0)
102
+ end
103
+
104
+ def close
105
+ @socket&.close
106
+ rescue StandardError
107
+ nil # already closed / never opened - nothing to do
108
+ ensure
109
+ @socket = nil
110
+ end
111
+
112
+ private
113
+
114
+ def delete(filter, limit)
115
+ command(
116
+ "delete" => @collection,
117
+ "deletes" => [{ "q" => filter, "limit" => limit }],
118
+ "$db" => @database
119
+ )
120
+ end
121
+
122
+ # Send one OP_MSG command and return the decoded reply document.
123
+ #
124
+ # RAISES MongoWireError on a transport failure or on `ok != 1`, so the
125
+ # Session boundary can log loud and degrade. A genuine MISS is not a
126
+ # failure and comes back as an empty firstBatch, never as an exception -
127
+ # collapsing the two is how a dead backend silently logs everyone out.
128
+ #
129
+ # The `ok` check lives HERE, outside the transport, on purpose. Folding it
130
+ # into #exchange would need a `rescue MongoWireError; raise` guard to stop
131
+ # a command error being relabelled as a transport failure, and that guard
132
+ # would also skip the socket drop for a genuine half-read reply - leaving
133
+ # a poisoned socket for the next caller to trip over.
134
+ def command(document)
135
+ reply = exchange(document)
136
+ return reply if reply["ok"].to_f == 1.0
137
+
138
+ raise MongoWireError, "MongoDB command failed: #{reply["errmsg"] || reply.inspect}"
139
+ end
140
+
141
+ # One request, one reply, on the shared socket. ANY failure in here is a
142
+ # transport failure, so the socket is dropped and the next command
143
+ # reconnects rather than inheriting the damage.
144
+ def exchange(document)
145
+ socket = connection
146
+ @request_id += 1
147
+ body = "\x00\x00\x00\x00".b + "\x00".b + encode_document(document) # flagBits(4) + section kind 0
148
+ header = [16 + body.bytesize, @request_id, 0, OP_MSG].pack("V4")
149
+ socket.write(header + body)
150
+ read_reply(socket)
151
+ rescue StandardError => e
152
+ close
153
+ raise MongoWireError, "MongoDB transport failed: #{e.message}"
154
+ end
155
+
156
+ def connection
157
+ @socket ||= begin
158
+ socket = Socket.tcp(@host, @port, connect_timeout: @timeout)
159
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [@timeout, 0].pack("l_2"))
160
+ socket
161
+ end
162
+ end
163
+
164
+ def read_reply(socket)
165
+ header = read_exact(socket, 16)
166
+ message_length = header.unpack1("V")
167
+ payload = read_exact(socket, message_length - 16)
168
+ # OP_MSG payload: flagBits(4) + section kind(1), then the body document.
169
+ decode_document(payload, [5])
170
+ end
171
+
172
+ def read_exact(socket, count)
173
+ buffer = +"".b
174
+ while buffer.bytesize < count
175
+ chunk = socket.read(count - buffer.bytesize)
176
+ raise MongoWireError, "connection closed mid-reply" if chunk.nil? || chunk.empty?
177
+
178
+ buffer << chunk
179
+ end
180
+ buffer
181
+ end
182
+
183
+ # ── Minimal BSON encoder ────────────────────────────────────────────
184
+
185
+ def encode_document(document)
186
+ body = +"".b
187
+ document.each { |key, value| body << encode_element(key.to_s, value) }
188
+ body << "\x00".b
189
+ [body.bytesize + 4].pack("V") + body
190
+ end
191
+
192
+ def encode_element(key, value)
193
+ ckey = key.b + "\x00".b
194
+
195
+ case value
196
+ when nil then TYPE_NULL.chr + ckey
197
+ when true, false then TYPE_BOOLEAN.chr + ckey + (value ? "\x01".b : "\x00".b)
198
+ when Integer then encode_integer(ckey, value)
199
+ when Float then TYPE_DOUBLE.chr + ckey + [value].pack("E")
200
+ when Time then TYPE_DATETIME.chr + ckey + [(value.to_f * 1000).round].pack("q<")
201
+ when Hash then TYPE_DOCUMENT.chr + ckey + encode_document(value)
202
+ when Array then TYPE_ARRAY.chr + ckey + encode_document(indexed(value))
203
+ else encode_string(ckey, value.to_s)
204
+ end
205
+ end
206
+
207
+ def encode_integer(ckey, value)
208
+ return TYPE_INT32.chr + ckey + [value].pack("l<") if value.between?(INT32_MIN, INT32_MAX)
209
+
210
+ TYPE_INT64.chr + ckey + [value].pack("q<")
211
+ end
212
+
213
+ # A BSON string is a BYTE count followed by the bytes and a terminator, so
214
+ # the length must be bytesize - a UTF-8 payload counted in characters
215
+ # under-reports and the server rejects the document.
216
+ #
217
+ # This is also the catch-all for the encoder: a String, a Symbol and
218
+ # anything else this codec has no BSON type for all become strings, which
219
+ # is what Python, PHP and Node all do at the same point.
220
+ def encode_string(ckey, value)
221
+ bytes = value.b
222
+ TYPE_STRING.chr + ckey + [bytes.bytesize + 1].pack("V") + bytes + "\x00".b
223
+ end
224
+
225
+ # A BSON array is a document whose keys are the string indexes.
226
+ def indexed(array)
227
+ array.each_with_index.to_h { |value, index| [index.to_s, value] }
228
+ end
229
+
230
+ # ── Minimal BSON decoder ────────────────────────────────────────────
231
+ #
232
+ # +position+ is a single-element array used as a shared cursor, matching
233
+ # the Python master's `pos = [0]` - nested documents advance the one
234
+ # cursor rather than each level tracking its own offset.
235
+
236
+ def decode_document(data, position)
237
+ document_length = data[position[0], 4].unpack1("V")
238
+ position[0] += 4
239
+ finish = position[0] + document_length - 5
240
+
241
+ document = {}
242
+ while position[0] < finish
243
+ type = data.getbyte(position[0])
244
+ position[0] += 1
245
+
246
+ key_end = data.index("\x00".b, position[0])
247
+ key = data[position[0]...key_end].force_encoding(Encoding::UTF_8)
248
+ position[0] = key_end + 1
249
+
250
+ document[key] = decode_value(data, position, type, finish)
251
+ end
252
+
253
+ position[0] += 1 # document terminator
254
+ document
255
+ end
256
+
257
+ def decode_value(data, position, type, finish)
258
+ case type
259
+ when TYPE_DOUBLE then take(data, position, 8).unpack1("E")
260
+ when TYPE_STRING then decode_string(data, position)
261
+ when TYPE_DOCUMENT then decode_document(data, position)
262
+ when TYPE_ARRAY then decode_document(data, position).values
263
+ when TYPE_BINARY then decode_binary(data, position)
264
+ when TYPE_OBJECT_ID then take(data, position, 12).unpack1("H*")
265
+ when TYPE_BOOLEAN then take(data, position, 1).getbyte(0) != 0
266
+ when TYPE_DATETIME then Time.at(take(data, position, 8).unpack1("q<") / 1000.0)
267
+ when TYPE_NULL then nil
268
+ when TYPE_INT32 then take(data, position, 4).unpack1("l<")
269
+ when TYPE_TIMESTAMP then take(data, position, 8).unpack1("Q<")
270
+ when TYPE_INT64 then take(data, position, 8).unpack1("q<")
271
+ else
272
+ # An unknown type cannot be sized, so its length cannot be skipped.
273
+ # Stop at the document boundary rather than loop forever on a byte
274
+ # this codec does not understand.
275
+ position[0] = finish
276
+ nil
277
+ end
278
+ end
279
+
280
+ def decode_string(data, position)
281
+ length = take(data, position, 4).unpack1("V")
282
+ value = data[position[0], length - 1].force_encoding(Encoding::UTF_8)
283
+ position[0] += length
284
+ value
285
+ end
286
+
287
+ def decode_binary(data, position)
288
+ length = take(data, position, 4).unpack1("V")
289
+ position[0] += 1 # subtype
290
+ take(data, position, length)
291
+ end
292
+
293
+ def take(data, position, count)
294
+ slice = data[position[0], count]
295
+ position[0] += count
296
+ slice
297
+ end
298
+ end
299
+ end
300
+ end
@@ -5,7 +5,9 @@ require_relative "resp_client"
5
5
  module Tina4
6
6
  module SessionHandlers
7
7
  # Redis-backed session handler. Prefers the `redis` gem when it is installed
8
- # (parity with the Python redis-py and Node redis-npm handlers); otherwise
8
+ # (parity with Python, which prefers redis-py the same way, INSIDE this one
9
+ # handler rather than as a separate backend name — Node's `redis-npm`
10
+ # backend did the latter and was retired 2026-07-31 as drift); otherwise
9
11
  # speaks raw RESP over a TCP socket via RespClient — zero dependencies, so a
10
12
  # Tina4 app stores sessions in Redis with no extra gem.
11
13
  class RedisHandler
@@ -14,8 +16,10 @@ module Tina4
14
16
  # TINA4_SESSION_BACKEND=redis can actually be pointed at a server by env.
15
17
  # An explicit constructor option always wins over the environment.
16
18
  def initialize(options = {})
17
- @prefix = options[:prefix] || "tina4:session:"
18
- @ttl = options[:ttl] || 86400
19
+ @prefix = options[:prefix] || ENV["TINA4_SESSION_REDIS_PREFIX"] || "tina4:session:"
20
+ # TINA4_SESSION_TTL reaches every backend (ADR-0024); was a hard-coded
21
+ # 86400. 3600 matches Python (the master), PHP and Node.
22
+ @ttl = (options[:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
19
23
  @host = options[:host] || ENV["TINA4_SESSION_REDIS_HOST"] || "localhost"
20
24
  @port = options[:port] || (ENV["TINA4_SESSION_REDIS_PORT"] ? ENV["TINA4_SESSION_REDIS_PORT"].to_i : 6379)
21
25
  @db = options[:db] || (ENV["TINA4_SESSION_REDIS_DB"] ? ENV["TINA4_SESSION_REDIS_DB"].to_i : 0)
@@ -33,13 +37,23 @@ module Tina4
33
37
  nil
34
38
  end
35
39
 
36
- def write(session_id, data)
40
+ # Write session data. A per-call +ttl+ WINS over the handler default, so
41
+ # asking for a 60s session really gets 60s; 0 uses the default. Every
42
+ # handler in every Tina4 framework takes this third argument -- Session#write
43
+ # passes it, and a handler that did not accept it raised ArgumentError,
44
+ # which safe_write swallowed into a silent STALE write.
45
+ #
46
+ # @param session_id [String] the session id
47
+ # @param data [Hash] the payload to store
48
+ # @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
49
+ def write(session_id, data, ttl = 0)
37
50
  key = "#{@prefix}#{session_id}"
38
51
  payload = JSON.generate(data)
52
+ effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
39
53
  if @redis
40
- @redis.setex(key, @ttl, payload)
54
+ @redis.setex(key, effective_ttl, payload)
41
55
  else
42
- @resp.setex(key, @ttl, payload)
56
+ @resp.setex(key, effective_ttl, payload)
43
57
  end
44
58
  end
45
59
 
@@ -11,7 +11,11 @@ module Tina4
11
11
  class ValkeyHandler
12
12
  def initialize(options = {})
13
13
  @prefix = options[:prefix] || ENV["TINA4_SESSION_VALKEY_PREFIX"] || "tina4:session:"
14
- @ttl = options[:ttl] || (ENV["TINA4_SESSION_VALKEY_TTL"] ? ENV["TINA4_SESSION_VALKEY_TTL"].to_i : 86400)
14
+ # TINA4_SESSION_VALKEY_TTL stays as the valkey-specific override, but it
15
+ # now falls back to TINA4_SESSION_TTL - the ONE session-lifetime variable
16
+ # every backend must honour (ADR-0024) - instead of a hard-coded 86400.
17
+ # 3600 matches Python (the master), PHP and Node.
18
+ @ttl = (options[:ttl] || ENV["TINA4_SESSION_VALKEY_TTL"] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
15
19
  @host = options[:host] || ENV["TINA4_SESSION_VALKEY_HOST"] || "localhost"
16
20
  @port = options[:port] || (ENV["TINA4_SESSION_VALKEY_PORT"] ? ENV["TINA4_SESSION_VALKEY_PORT"].to_i : 6379)
17
21
  @db = options[:db] || (ENV["TINA4_SESSION_VALKEY_DB"] ? ENV["TINA4_SESSION_VALKEY_DB"].to_i : 0)
@@ -29,13 +33,23 @@ module Tina4
29
33
  nil
30
34
  end
31
35
 
32
- def write(session_id, data)
36
+ # Write session data. A per-call +ttl+ WINS over the handler default, so
37
+ # asking for a 60s session really gets 60s; 0 uses the default. Every
38
+ # handler in every Tina4 framework takes this third argument -- Session#write
39
+ # passes it, and a handler that did not accept it raised ArgumentError,
40
+ # which safe_write swallowed into a silent STALE write.
41
+ #
42
+ # @param session_id [String] the session id
43
+ # @param data [Hash] the payload to store
44
+ # @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
45
+ def write(session_id, data, ttl = 0)
33
46
  key = "#{@prefix}#{session_id}"
34
47
  payload = JSON.generate(data)
48
+ effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
35
49
  if @redis
36
- @redis.setex(key, @ttl, payload)
50
+ @redis.setex(key, effective_ttl, payload)
37
51
  else
38
- @resp.setex(key, @ttl, payload)
52
+ @resp.setex(key, effective_ttl, payload)
39
53
  end
40
54
  end
41
55
 
@@ -1,21 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
+ # Graceful shutdown on SIGTERM / SIGINT.
5
+ #
6
+ # The order is the contract, identical in all four Tina4 frameworks:
7
+ #
8
+ # 1. Stop accepting FIRST. The listening socket closes before anything is
9
+ # drained, so a connection arriving after the signal gets a clean
10
+ # CONNECTION REFUSED - not a 503, not a TCP reset.
11
+ # 2. Tell live WebSocket peers we are going away (RFC 6455 close code 1001).
12
+ # 3. Drain in-flight requests, bounded by TINA4_SHUTDOWN_TIMEOUT.
13
+ # 4. Stop background tasks, close database connections, exit 0.
14
+ #
15
+ # SIGHUP is deliberately NOT trapped: the Rust CLI owns file watching and
16
+ # production logs go to stdout, so neither Puma's log-reopen nor gunicorn's
17
+ # config-reload use for SIGHUP is a Tina4 need.
4
18
  module Shutdown
19
+ # Matches Kubernetes' default terminationGracePeriodSeconds and Gunicorn's
20
+ # graceful_timeout, and is the same default in Python, PHP and Node.
5
21
  DEFAULT_TIMEOUT = 30 # seconds
6
22
 
7
23
  class << self
8
24
  attr_reader :in_flight_count
9
25
 
10
- def setup(server: nil, timeout: nil)
26
+ # The resolved TINA4_SHUTDOWN_TIMEOUT. Public because the production path
27
+ # does not drain in Ruby at all - it maps this onto Puma's own
28
+ # force_shutdown_after so the documented env var means the same thing
29
+ # whichever server owns the socket.
30
+ attr_reader :timeout
31
+
32
+ # trap_signals: false when another server owns INT/TERM (Puma does). A
33
+ # Tina4 trap on that path would be installed but never usefully serviced:
34
+ # there is no listener to close and nothing calls track_request, so if the
35
+ # other server's own trap install ever failed, ours would swallow the
36
+ # default terminate and do nothing - the process would survive the signal.
37
+ def setup(server: nil, timeout: nil, trap_signals: true)
11
38
  @server = server
12
- @timeout = (timeout || ENV["TINA4_SHUTDOWN_TIMEOUT"] || DEFAULT_TIMEOUT).to_i
39
+ @timeout = resolve_timeout(timeout)
13
40
  @shutting_down = false
41
+ @shutdown_complete = false
14
42
  @mutex = Mutex.new
15
43
  @in_flight_count = 0
16
44
  @in_flight_cv = ConditionVariable.new
17
45
 
18
- install_signal_handlers
46
+ install_signal_handlers if trap_signals
19
47
  end
20
48
 
21
49
  def shutting_down?
@@ -40,51 +68,173 @@ module Tina4
40
68
  @shutting_down = true
41
69
  Tina4::Log.info("Shutdown signal received, stopping gracefully...")
42
70
 
43
- # Wait for in-flight requests with timeout
71
+ stop_accepting
72
+ drained = wait_for_in_flight
73
+ release_resources
74
+
75
+ Tina4::Log.info("Shutdown complete")
76
+ @shutdown_complete = true
77
+ return if drained
78
+
79
+ # The deadline expired with requests still in flight. WEBrick's accept
80
+ # loop joins its worker threads with NO timeout of its own, so simply
81
+ # returning here would hang the process for as long as the slowest
82
+ # handler runs - exactly what TINA4_SHUTDOWN_TIMEOUT exists to prevent.
83
+ # Flush first: exit! runs no at_exit handlers and does not flush stdio,
84
+ # which would swallow the warning that explains the forced exit.
85
+ $stdout.flush
86
+ $stderr.flush
87
+ exit!(0)
88
+ end
89
+
90
+ # The teardown NO web server can do for us, because no web server knows
91
+ # these things exist: live WebSocket peers owed an RFC 6455 close frame,
92
+ # Tina4 background threads, and ORM-bound database connections.
93
+ #
94
+ # Public so the production path can run exactly the same teardown from an
95
+ # ensure around Puma's launcher: Puma owns the socket, the drain and the
96
+ # signals there, but a database connection it has never heard of would
97
+ # otherwise leak on every single shutdown.
98
+ def release_resources
99
+ close_websockets
100
+ stop_background_tasks
101
+ close_database
102
+ end
103
+
104
+ # Block until initiate_shutdown has finished every teardown step.
105
+ #
106
+ # stop_accepting unblocks WEBrick's accept loop immediately, so its #start
107
+ # returns as soon as the in-flight workers are joined - which can be while
108
+ # the signal handler's thread is still stopping background tasks and
109
+ # closing database connections. The server's main thread calls this so the
110
+ # process does not exit out from under that teardown.
111
+ def wait_for_completion(timeout = nil)
112
+ return unless @shutting_down
113
+
114
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
115
+ (timeout || @timeout.to_f + 5)
116
+ until @shutdown_complete || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
117
+ sleep 0.02
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ # TINA4_SHUTDOWN_TIMEOUT in seconds (same name and default in all four
124
+ # frameworks). An unparseable or negative value warns and falls back to the
125
+ # default: `"abc".to_i` is 0, and a silent 0-second drain would force-close
126
+ # every in-flight request on the first signal. An explicit 0 is honoured -
127
+ # that is a deliberate choice, not a silent one.
128
+ def resolve_timeout(explicit)
129
+ raw = explicit || ENV["TINA4_SHUTDOWN_TIMEOUT"]
130
+ return DEFAULT_TIMEOUT if raw.nil? || raw.to_s.strip.empty?
131
+
132
+ seconds = Float(raw)
133
+ raise ArgumentError, "must not be negative" if seconds.negative?
134
+
135
+ seconds
136
+ rescue ArgumentError, TypeError
137
+ Tina4::Log.warning(
138
+ "TINA4_SHUTDOWN_TIMEOUT=#{raw.inspect} is not a non-negative number, using #{DEFAULT_TIMEOUT}s"
139
+ )
140
+ DEFAULT_TIMEOUT
141
+ end
142
+
143
+ # Close the listening socket BEFORE draining. WEBrick's #shutdown wakes its
144
+ # accept loop and closes the listeners; it does not touch the worker
145
+ # threads already running, which the loop's own ensure joins - so new
146
+ # connections are refused while in-flight requests still run to completion
147
+ # and write their full response. Measured on webrick 1.9.2 / Ruby 4.0.2:
148
+ # #shutdown returns in ~0.1ms, a connection attempted 0.3s later is
149
+ # ECONNREFUSED, and the 2s handler mid-flight still returns a full 200.
150
+ def stop_accepting
151
+ return unless @server.respond_to?(:shutdown)
152
+
153
+ @server.shutdown
154
+ Tina4::Log.info("Stopped accepting new connections")
155
+ rescue StandardError => e
156
+ Tina4::Log.error("Error closing the listening socket: #{e.message}")
157
+ end
158
+
159
+ # RFC 6455 close code 1001 "going away" is what a server sends when it is
160
+ # shutting down, so clients reconnect elsewhere instead of treating the
161
+ # drop as an error. Both live managers are covered: the process-wide route
162
+ # engine (Tina4::WebSocket.current, published by RackApp) and the
163
+ # dev-reload channel's own manager - the same pair DevAdmin#ws_managers
164
+ # enumerates.
165
+ def close_websockets
166
+ closed = 0
167
+ live_websocket_managers.each do |manager|
168
+ manager.connections.values.each do |connection|
169
+ connection.close(code: 1001, reason: "going away")
170
+ closed += 1
171
+ rescue StandardError => e
172
+ Tina4::Log.warning("Error closing WebSocket #{connection.id}: #{e.message}")
173
+ end
174
+ end
175
+ Tina4::Log.info("Closed #{closed} WebSocket connection(s) with 1001 going away") if closed.positive?
176
+ rescue StandardError => e
177
+ Tina4::Log.error("Error closing WebSocket connections: #{e.message}")
178
+ end
179
+
180
+ def live_websocket_managers
181
+ managers = []
182
+ return managers unless defined?(Tina4::WebSocket)
183
+
184
+ managers << Tina4::WebSocket.current if Tina4::WebSocket.current
185
+ managers << Tina4::DevReload.manager if defined?(Tina4::DevReload)
186
+ managers
187
+ end
188
+
189
+ # true when everything drained, false when the deadline expired.
190
+ def wait_for_in_flight
44
191
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
45
192
  @mutex.synchronize do
46
193
  while @in_flight_count > 0
47
194
  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
195
  if remaining <= 0
49
- Tina4::Log.warning("Shutdown timeout reached with #{@in_flight_count} requests still in flight")
50
- break
196
+ Tina4::Log.warning(
197
+ "TINA4_SHUTDOWN_TIMEOUT=#{@timeout}s reached with #{@in_flight_count} " \
198
+ "request(s) still in flight, forcing close"
199
+ )
200
+ return false
51
201
  end
52
202
  @in_flight_cv.wait(@mutex, remaining)
53
203
  end
54
204
  end
205
+ true
206
+ end
55
207
 
56
- # Stop background tasks
57
- if defined?(Tina4::Background)
58
- begin
59
- Tina4::Background.stop_all
60
- Tina4::Log.info("Background tasks stopped")
61
- rescue => e
62
- Tina4::Log.error("Error stopping background tasks: #{e.message}")
63
- end
64
- end
208
+ def stop_background_tasks
209
+ return unless defined?(Tina4::Background)
65
210
 
66
- # Close database connections
67
- if Tina4.database
68
- begin
69
- Tina4.database.close
70
- Tina4::Log.info("Database connections closed")
71
- rescue => e
72
- Tina4::Log.error("Error closing database: #{e.message}")
73
- end
74
- end
211
+ Tina4::Background.stop_all
212
+ Tina4::Log.info("Background tasks stopped")
213
+ rescue StandardError => e
214
+ Tina4::Log.error("Error stopping background tasks: #{e.message}")
215
+ end
75
216
 
76
- Tina4::Log.info("Shutdown complete")
217
+ # Close the DEFAULT connection and every NAMED one. Only closing
218
+ # Tina4.database leaked every connection registered with
219
+ # bind_database(db, name:) - a model pointed at a secondary database held
220
+ # its connection open through shutdown.
221
+ def close_database
222
+ connections = [Tina4.database, *Tina4.databases.values].compact.uniq(&:object_id)
223
+ return if connections.empty?
77
224
 
78
- # Stop the server
79
- @server&.shutdown if @server.respond_to?(:shutdown)
225
+ connections.each do |connection|
226
+ connection.close
227
+ rescue StandardError => e
228
+ Tina4::Log.error("Error closing database: #{e.message}")
229
+ end
230
+ Tina4::Log.info("Database connections closed (#{connections.size})")
80
231
  end
81
232
 
82
- private
83
-
84
233
  def install_signal_handlers
85
234
  %w[INT TERM].each do |signal|
86
235
  Signal.trap(signal) do
87
- # Signal handlers must be async-signal-safe; use Thread to do real work
236
+ # A trap context must stay async-signal-safe (no mutexes, no IO), so
237
+ # the real work happens on a thread.
88
238
  Thread.new { initiate_shutdown }
89
239
  end
90
240
  end