tina4ruby 3.13.137 → 3.13.139

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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +74 -8
  12. data/lib/tina4/auth.rb +108 -26
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +14 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +9 -1
  26. data/lib/tina4/cli.rb +27 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +222 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +16 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +14 -6
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +200 -58
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +134 -35
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +21 -2
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +18 -9
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +27 -10
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +22 -4
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +18 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
  112. data/lib/tina4/rack_app.rb +62 -50
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +15 -1
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +90 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +42 -12
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +52 -12
  146. data/lib/tina4/ssrf.rb +148 -0
  147. data/lib/tina4/swagger.rb +6 -0
  148. data/lib/tina4/template.rb +6 -0
  149. data/lib/tina4/test.rb +8 -2
  150. data/lib/tina4/test_client.rb +8 -1
  151. data/lib/tina4/testing.rb +8 -1
  152. data/lib/tina4/validator.rb +6 -0
  153. data/lib/tina4/version.rb +7 -1
  154. data/lib/tina4/webserver.rb +25 -143
  155. data/lib/tina4/websocket.rb +170 -20
  156. data/lib/tina4/websocket_backplane.rb +25 -2
  157. data/lib/tina4/wsdl.rb +54 -18
  158. data/lib/tina4/xml_parser.rb +268 -0
  159. data/lib/tina4.rb +98 -33
  160. data/lib/tina4ruby.rb +6 -0
  161. metadata +44 -140
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
4
10
 
@@ -211,25 +217,49 @@ module Tina4
211
217
  # spells "already exists" differently ("There is already an object named",
212
218
  # "already exists", SQLSTATE 42S01) and a string match would rot the first
213
219
  # time an engine reworded itself or ran under another locale.
220
+ #
221
+ # The re-check is RETRIED, a bounded number of times, because the loser
222
+ # can fail BEFORE the winner has committed. MEASURED on MySQL 8.4: CREATE
223
+ # TABLE IF NOT EXISTS takes a shared metadata lock on the name, checks,
224
+ # then upgrades it to exclusive, and two sessions upgrading at once is a
225
+ # metadata-lock deadlock. MySQL backs the victim off silently only when the
226
+ # session holds no other metadata lock; otherwise the victim gets 1213
227
+ # "Deadlock found" while the winner's table is still invisible, and one
228
+ # immediate re-check turned that lost race into a failed request (tina4-php
229
+ # CI run 35972320442). Same attempts and delays in all four frameworks.
230
+ CREATE_ATTEMPTS = 5
231
+ CREATE_RETRY_DELAY = 0.05
232
+
214
233
  def ensure_table
215
234
  return if @table_ready
216
235
 
217
236
  @table_ready = true
218
- begin
219
- db.execute(create_table_sql)
220
- rescue StandardError
221
- # A failed statement leaves PostgreSQL's transaction ABORTED, so
222
- # without this the re-check would fail for the wrong reason and report
223
- # a missing table that is right there. Best effort: an engine with no
224
- # open transaction is entitled to object to being rolled back.
237
+ attempt = 1
238
+ until db.table_exists?(TABLE_NAME)
225
239
  begin
226
- db.rollback
240
+ db.execute(create_table_sql)
241
+ return
227
242
  rescue StandardError
228
- nil
243
+ # A failed statement leaves PostgreSQL's transaction ABORTED, so
244
+ # without this the re-check would fail for the wrong reason and
245
+ # report a missing table that is right there. Best effort: an engine
246
+ # with no open transaction is entitled to object to being rolled back.
247
+ begin
248
+ db.rollback
249
+ rescue StandardError
250
+ nil
251
+ end
252
+ # Somebody else created it - that is the race, and it is a success.
253
+ # Still absent after the last attempt is a real failure, re-raised
254
+ # untouched.
255
+ if attempt >= CREATE_ATTEMPTS
256
+ return if db.table_exists?(TABLE_NAME)
257
+
258
+ raise
259
+ end
260
+ sleep(CREATE_RETRY_DELAY * attempt)
261
+ attempt += 1
229
262
  end
230
- # Somebody else created it - that is the race, and it is a success.
231
- # Anything else is a real failure and is re-raised untouched.
232
- raise unless db.table_exists?(TABLE_NAME)
233
263
  end
234
264
  end
235
265
  end
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "json"
3
9
  require "fileutils"
4
10
  require "digest"
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "digest"
4
10
  require "json"
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "json"
3
9
  require_relative "mongo_wire_client"
4
10
 
@@ -51,36 +57,90 @@ module Tina4
51
57
  @database = options[:database] || ENV["TINA4_SESSION_MONGO_DB"] || "tina4"
52
58
  @collection_name = options[:collection] || ENV["TINA4_SESSION_MONGO_COLLECTION"] || "sessions"
53
59
  @gem_available = gem_available?
54
- @client = nil
55
- @wire_client = nil
56
- @collection = nil
57
- @index_ready = false
58
60
  end
59
61
 
60
- # Release whichever transport was opened.
62
+ # ONE TRANSPORT PER PROCESS (issue #136, parity with tina4-python #136).
61
63
  #
62
- # Mongo::Client owns a pool of REAL sockets. Before the client was lazy it
63
- # was a local variable in #initialize, reachable only through the
64
- # collection, so every construction leaked a pool and nothing could give it
65
- # back. Now the handler holds the client, so the handler can close it -
66
- # parity with the Python master's MongoDBSessionHandler.close() and with
67
- # Tina4::DocStore.close_doc_store, which already closes its Mongo clients
68
- # the same way. The zero-dependency transport owns ONE raw socket and is
69
- # closed the same way, so neither path leaks. Safe to call on a handler
70
- # that never connected.
71
- def close
72
- # Each close is guarded on its own: a failure on one transport must not
73
- # skip the other, and neither may mask the caller's work.
74
- [@client, @wire_client].each do |transport|
64
+ # Every request builds a Session and every Session builds a NEW handler,
65
+ # so a transport owned by the handler is a transport per REQUEST. The
66
+ # handler used to open its own Mongo::Client on first use - each with its
67
+ # own SDAM monitor threads and connection pool - and nothing ever closed
68
+ # it. MEASURED on the lab: 21 requests left 87 extra threads behind; on the
69
+ # zero-dependency path 40 sessions left 40 sockets open to MongoDB.
70
+ #
71
+ # A Mongo::Client is thread-safe and pools internally, so the driver
72
+ # expects exactly one per (uri, database) - the same shape
73
+ # Tina4::DocStore.mongo_client already uses. The zero-dependency
74
+ # MongoWireClient is bound to one collection and serialises its commands
75
+ # on its single socket, so it is shared per (uri, database, collection).
76
+ # Handlers hold no transport of their own: they look it up here on every
77
+ # operation, so a released transport is simply rebuilt on next use.
78
+ @shared = {}
79
+ @index_ready = {}
80
+ @shared_lock = Mutex.new
81
+
82
+ class << self
83
+ # The shared transport for +key+, built by the block on first use. The
84
+ # double-checked lock stops two threads racing the first request from
85
+ # both building a client and orphaning one - the same leak, just rarer.
86
+ def shared_transport(key)
87
+ transport = @shared[key]
88
+ return transport if transport
89
+
90
+ @shared_lock.synchronize { @shared[key] ||= yield }
91
+ end
92
+
93
+ # True exactly once per process for +key+ - used so the TTL index round
94
+ # trip runs once per (store, ttl), not once per request.
95
+ def claim_index(key)
96
+ @shared_lock.synchronize do
97
+ next false if @index_ready[key]
98
+
99
+ @index_ready[key] = true
100
+ end
101
+ end
102
+
103
+ # Close and forget the shared transport for +key+ (MongoHandler#close).
104
+ def release_shared(key)
105
+ close_transport(@shared_lock.synchronize { @shared.delete(key) })
106
+ end
107
+
108
+ # Close every shared session transport - process shutdown, or a test
109
+ # that must leave no connection behind. Safe to call at any time: the
110
+ # next session operation simply reconnects.
111
+ def close_shared_clients
112
+ transports = @shared_lock.synchronize do
113
+ values = @shared.values
114
+ @shared.clear
115
+ @index_ready.clear
116
+ values
117
+ end
118
+ transports.each { |transport| close_transport(transport) }
119
+ true
120
+ end
121
+
122
+ private
123
+
124
+ # A close failure must never mask the caller's work.
125
+ def close_transport(transport)
75
126
  transport&.close
76
127
  rescue StandardError
77
128
  nil
78
129
  end
79
- ensure
80
- @client = nil
81
- @wire_client = nil
82
- @collection = nil
83
- @index_ready = false
130
+ end
131
+
132
+ # Release the transport this handler's configuration uses.
133
+ #
134
+ # The transport is SHARED per process (see .shared_transport), so this
135
+ # closes it for every handler pointed at the same store - exactly what an
136
+ # owner that is done with the store wants, and harmless to the rest: they
137
+ # look the transport up on every operation and reconnect on next use. The
138
+ # Mongo::Client (a pool of real sockets) and the zero-dependency socket
139
+ # are both closed this way, so neither path leaks. Safe to call on a
140
+ # handler that never connected. Parity with the Python master's
141
+ # MongoDBSessionHandler.close() and Tina4::DocStore.close_doc_store.
142
+ def close
143
+ self.class.release_shared(transport_key)
84
144
  end
85
145
 
86
146
  # Decide whether a stored document has expired, FROM THE DOCUMENT ALONE.
@@ -189,17 +249,25 @@ module Tina4
189
249
  # handler takes the other. A per-operation branch is a branch that can be
190
250
  # wrong on one path only - which is exactly how a fallback ships untested.
191
251
  def collection
192
- return @collection if @collection
252
+ if @gem_available
253
+ client = self.class.shared_transport(transport_key) { Mongo::Client.new(@uri, database: @database) }
254
+ collection = client[@collection_name]
255
+ ensure_ttl_index(collection)
256
+ collection
257
+ else
258
+ self.class.shared_transport(transport_key) { build_wire_client }
259
+ end
260
+ end
193
261
 
262
+ # The registry key for this handler's transport: one Mongo::Client per
263
+ # (uri, database); one wire client per (uri, database, collection), since
264
+ # a MongoWireClient is bound to a single collection.
265
+ def transport_key
194
266
  if @gem_available
195
- @client = Mongo::Client.new(@uri, database: @database)
196
- @collection = @client[@collection_name]
197
- ensure_ttl_index
267
+ [:gem, @uri, @database]
198
268
  else
199
- @wire_client = build_wire_client
200
- @collection = @wire_client
269
+ [:wire, @uri, @database, @collection_name]
201
270
  end
202
- @collection
203
271
  end
204
272
 
205
273
  # The zero-dependency transport, pointed at the host and port parsed out of
@@ -258,18 +326,19 @@ module Tina4
258
326
  # does not exist when the gem is absent, so evaluating them would raise
259
327
  # NameError rather than the LoadError anyone would expect.
260
328
  #
261
- # Runs ONCE per handler (@index_ready), on the first operation. The flag is
262
- # set BEFORE the round trip so an unreachable server is not re-probed on
263
- # every read - the same ordering the Python master uses for _table_ready.
264
- def ensure_ttl_index
265
- return if @index_ready
329
+ # Runs ONCE per process for each (store, collection, ttl), on the first
330
+ # operation - not once per handler, which is once per REQUEST (issue
331
+ # #136). The claim is taken BEFORE the round trip so an unreachable server
332
+ # is not re-probed on every read - the same ordering the Python master
333
+ # uses for _table_ready.
334
+ def ensure_ttl_index(collection)
335
+ return unless self.class.claim_index([@uri, @database, @collection_name, @ttl])
266
336
 
267
- @index_ready = true
268
- @collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
337
+ collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
269
338
  rescue Mongo::Error::OperationFailure => e
270
339
  raise unless e.code == 85 || e.message.include?("IndexOptionsConflict")
271
- @collection.indexes.drop_one("updated_at_1")
272
- @collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
340
+ collection.indexes.drop_one("updated_at_1")
341
+ collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
273
342
  rescue Mongo::Error => e
274
343
  # The index is the background REAPER, not the expiry authority - #read
275
344
  # checks expires_at on the document itself - so failing to create it must
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "socket"
4
10
 
@@ -71,6 +77,10 @@ module Tina4
71
77
  @timeout = timeout
72
78
  @socket = nil
73
79
  @request_id = 0
80
+ # One socket carries one request/reply at a time. The client is SHARED
81
+ # by every session in the process (MongoHandler.shared_transport, issue
82
+ # #136), so concurrent requests must not interleave their frames.
83
+ @lock = Mutex.new
74
84
  end
75
85
 
76
86
  # Documents matching +filter+, capped at one.
@@ -142,15 +152,19 @@ module Tina4
142
152
  # transport failure, so the socket is dropped and the next command
143
153
  # reconnects rather than inheriting the damage.
144
154
  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}"
155
+ @lock.synchronize do
156
+ socket = connection
157
+ @request_id += 1
158
+ body = "\x00\x00\x00\x00".b + "\x00".b + encode_document(document) # flagBits(4) + section kind 0
159
+ header = [16 + body.bytesize, @request_id, 0, OP_MSG].pack("V4")
160
+ socket.write(header + body)
161
+ read_reply(socket)
162
+ rescue StandardError => e
163
+ # Still under the lock: no other session can be mid-exchange on the
164
+ # socket being dropped. The next command reconnects.
165
+ close
166
+ raise MongoWireError, "MongoDB transport failed: #{e.message}"
167
+ end
154
168
  end
155
169
 
156
170
  def connection
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "json"
3
9
  require_relative "resp_client"
4
10
 
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "socket"
4
10
 
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "json"
3
9
  require_relative "resp_client"
4
10
 
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  # Graceful shutdown on SIGTERM / SIGINT.
@@ -80,10 +86,9 @@ module Tina4
80
86
  @shutdown_complete = true
81
87
  return if drained
82
88
 
83
- # The deadline expired with requests still in flight. WEBrick's accept
84
- # loop joins its worker threads with NO timeout of its own, so simply
85
- # returning here would hang the process for as long as the slowest
86
- # handler runs - exactly what TINA4_SHUTDOWN_TIMEOUT exists to prevent.
89
+ # The deadline expired with requests still in flight. Returning here
90
+ # would let the process live for as long as the slowest handler runs -
91
+ # exactly what TINA4_SHUTDOWN_TIMEOUT exists to prevent.
87
92
  # Flush first: exit! runs no at_exit handlers and does not flush stdio,
88
93
  # which would swallow the warning that explains the forced exit.
89
94
  $stdout.flush
@@ -107,10 +112,9 @@ module Tina4
107
112
 
108
113
  # Block until initiate_shutdown has finished every teardown step.
109
114
  #
110
- # stop_accepting unblocks WEBrick's accept loop immediately, so its #start
111
- # returns as soon as the in-flight workers are joined - which can be while
112
- # the signal handler's thread is still stopping background tasks and
113
- # closing database connections. The server's main thread calls this so the
115
+ # stop_accepting unblocks the built-in server's accept loop immediately, so
116
+ # its #start returns while the signal handler's thread is still draining
117
+ # requests, stopping background tasks and closing database connections. The server's main thread calls this so the
114
118
  # process does not exit out from under that teardown.
115
119
  def wait_for_completion(timeout = nil)
116
120
  return unless @shutting_down
@@ -144,13 +148,12 @@ module Tina4
144
148
  DEFAULT_TIMEOUT
145
149
  end
146
150
 
147
- # Close the listening socket BEFORE draining. WEBrick's #shutdown wakes its
148
- # accept loop and closes the listeners; it does not touch the worker
149
- # threads already running, which the loop's own ensure joins - so new
151
+ # Close the listening socket BEFORE draining. Tina4::HttpServer#shutdown
152
+ # closes every listener and every keep-alive connection that is waiting
153
+ # for its next request; it does not touch a request being served - so new
150
154
  # connections are refused while in-flight requests still run to completion
151
- # and write their full response. Measured on webrick 1.9.2 / Ruby 4.0.2:
152
- # #shutdown returns in ~0.1ms, a connection attempted 0.3s later is
153
- # ECONNREFUSED, and the 2s handler mid-flight still returns a full 200.
155
+ # and write their full response (spec/graceful_shutdown_spec.rb proves all
156
+ # three on a real SIGTERM).
154
157
  def stop_accepting
155
158
  return unless @server.respond_to?(:shutdown)
156
159
 
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+
9
+ require "socket"
10
+ require_relative "mail_socket"
11
+ require_relative "base64"
12
+
13
+ module Tina4
14
+ class Messenger
15
+ # Tina4's own SMTP client (RFC 5321), replacing the net-smtp gem. It covers
16
+ # exactly what Messenger sends with:
17
+ #
18
+ # * transport: plain, STARTTLS, or implicit TLS. Implicit TLS defaults to
19
+ # port 465, the rule Python (SMTP_SSL), PHP (ssl://) and Node (tls.connect)
20
+ # already follow; the caller may also ask for it on any port (tls: true).
21
+ # STARTTLS is REQUIRED when asked for: a server that does not offer it
22
+ # fails the send rather than silently getting the message and the
23
+ # credentials in clear (Net::SMTP#enable_starttls did the same).
24
+ # * AUTH PLAIN, or AUTH LOGIN when that is all the server offers.
25
+ # * MAIL FROM / RCPT TO / DATA with CRLF normalisation and dot-stuffing.
26
+ #
27
+ # SmtpClient.start(host: "smtp.example.com", port: 587, starttls: true,
28
+ # username: "u", password: "p") do |smtp|
29
+ # smtp.send_message(raw_message, "from@example.com", ["to@example.com"])
30
+ # end
31
+ class SmtpClient
32
+ # A server reply outside the expected codes; the message is the reply text.
33
+ class Error < StandardError
34
+ attr_reader :code
35
+
36
+ def initialize(code, message)
37
+ @code = code
38
+ super(message)
39
+ end
40
+ end
41
+
42
+ IMPLICIT_TLS_PORT = 465
43
+
44
+ def self.start(**options)
45
+ client = new(**options)
46
+ client.connect
47
+ return client unless block_given?
48
+
49
+ begin
50
+ yield client
51
+ ensure
52
+ client.quit
53
+ end
54
+ end
55
+
56
+ # auth_mechanism forces "PLAIN" or "LOGIN"; nil picks from the server's
57
+ # EHLO list (PLAIN preferred, as Net::SMTP's :plain always sent).
58
+ def initialize(host:, port:, tls: nil, starttls: false, username: nil, password: nil,
59
+ auth_mechanism: nil, timeout: 30, helo_domain: nil)
60
+ @host = host
61
+ @port = port.to_i
62
+ @implicit_tls = tls.nil? ? @port == IMPLICIT_TLS_PORT : tls
63
+ @starttls = starttls
64
+ @username = username
65
+ @password = password
66
+ @auth_mechanism = auth_mechanism&.upcase
67
+ @timeout = timeout
68
+ @helo_domain = helo_domain || local_host_name
69
+ @capabilities = []
70
+ @socket = nil
71
+ end
72
+
73
+ attr_reader :capabilities
74
+
75
+ def implicit_tls?
76
+ @implicit_tls
77
+ end
78
+
79
+ def tls?
80
+ @socket&.tls? || false
81
+ end
82
+
83
+ def connect
84
+ @socket = MailSocket.open(@host, @port, tls: implicit_tls?, timeout: @timeout)
85
+ read_reply(220)
86
+ greet
87
+ upgrade_to_tls if @starttls && !implicit_tls?
88
+ authenticate if @username && @password
89
+ self
90
+ rescue StandardError
91
+ close
92
+ raise
93
+ end
94
+
95
+ def send_message(message, from, recipients)
96
+ recipients = Array(recipients).map { |recipient| envelope_address(recipient) }
97
+ raise ArgumentError, "at least one recipient is required" if recipients.empty?
98
+
99
+ command("MAIL FROM:<#{envelope_address(from)}>", 250)
100
+ recipients.each { |recipient| command("RCPT TO:<#{recipient}>", 250, 251) }
101
+ command("DATA", 354)
102
+ @socket.write(self.class.data_block(message))
103
+ read_reply(250)
104
+ end
105
+
106
+ # QUIT politely, then close. Never raises: the message is already queued.
107
+ def quit
108
+ return unless @socket
109
+
110
+ begin
111
+ command("QUIT", 221)
112
+ rescue StandardError
113
+ nil
114
+ end
115
+ close
116
+ end
117
+
118
+ def close
119
+ @socket&.close
120
+ @socket = nil
121
+ end
122
+
123
+ # The DATA payload: every line ending normalised to CRLF, a line starting
124
+ # with "." doubled (RFC 5321 4.5.2), terminated by CRLF.CRLF.
125
+ def self.data_block(message)
126
+ data = message.to_s.b.gsub(/\r\n|\r|\n/n, "\r\n")
127
+ data << "\r\n" unless data.end_with?("\r\n")
128
+ data.gsub(/^\./n, "..") + ".\r\n"
129
+ end
130
+
131
+ private
132
+
133
+ def greet
134
+ @capabilities = command("EHLO #{@helo_domain}", 250).drop(1)
135
+ rescue Error => e
136
+ raise unless (500..599).cover?(e.code)
137
+
138
+ # A server that does not speak ESMTP: plain HELO, no extensions.
139
+ command("HELO #{@helo_domain}", 250)
140
+ @capabilities = []
141
+ end
142
+
143
+ def upgrade_to_tls
144
+ unless capability?("STARTTLS")
145
+ raise Error.new(0, "STARTTLS was requested but #{@host}:#{@port} does not offer it")
146
+ end
147
+
148
+ command("STARTTLS", 220)
149
+ @socket.start_tls
150
+ greet # capabilities change once the channel is encrypted (AUTH often appears)
151
+ end
152
+
153
+ def authenticate
154
+ case auth_mechanism
155
+ when "PLAIN"
156
+ command("AUTH PLAIN #{Tina4::Base64.strict_encode64("\0#{@username}\0#{@password}")}", 235)
157
+ when "LOGIN"
158
+ command("AUTH LOGIN", 334)
159
+ command(Tina4::Base64.strict_encode64(@username.to_s), 334)
160
+ command(Tina4::Base64.strict_encode64(@password.to_s), 235)
161
+ end
162
+ end
163
+
164
+ def auth_mechanism
165
+ return @auth_mechanism if @auth_mechanism
166
+
167
+ offered = @capabilities.filter_map { |line| line[/\AAUTH[ =](.*)\z/i, 1] }
168
+ .flat_map { |list| list.upcase.split }
169
+ return "PLAIN" if offered.empty? || offered.include?("PLAIN")
170
+ return "LOGIN" if offered.include?("LOGIN")
171
+
172
+ raise Error.new(0, "#{@host}:#{@port} offers no supported AUTH mechanism (offered: #{offered.join(' ')})")
173
+ end
174
+
175
+ def capability?(name)
176
+ @capabilities.any? { |line| line.split(/[ =]/, 2).first.casecmp?(name) }
177
+ end
178
+
179
+ # Send one command line and read its reply; returns the reply lines.
180
+ def command(line, *expected)
181
+ if line.match?(/[\r\n]/)
182
+ raise ArgumentError, "SMTP command contains a line break"
183
+ end
184
+
185
+ @socket.write("#{line}\r\n")
186
+ read_reply(*expected)
187
+ end
188
+
189
+ # A reply is one or more "NNN-text" lines ending with "NNN text".
190
+ def read_reply(*expected)
191
+ lines = []
192
+ code = nil
193
+ loop do
194
+ raw = @socket.read_line.force_encoding(Encoding::UTF_8).scrub.chomp
195
+ code = raw[0, 3].to_i
196
+ lines << raw[4..].to_s
197
+ break unless raw[3] == "-"
198
+ end
199
+ return lines if expected.empty? || expected.include?(code)
200
+
201
+ raise Error.new(code, "#{code} #{lines.join(' ')}".strip)
202
+ end
203
+
204
+ # The bare address for the envelope: "Name <a@b>" -> "a@b". CR, LF and
205
+ # angle brackets inside it would let a caller inject SMTP commands.
206
+ def envelope_address(address)
207
+ text = address.to_s.strip
208
+ text = Regexp.last_match(1).strip if text =~ /<([^<>]*)>\s*\z/
209
+ raise ArgumentError, "invalid email address #{address.inspect}" if text.match?(/[\r\n<>]/)
210
+
211
+ text
212
+ end
213
+
214
+ def local_host_name
215
+ name = Socket.gethostname.to_s
216
+ name.match?(/\A[A-Za-z0-9.\-]+\z/) ? name : "localhost"
217
+ rescue StandardError
218
+ "localhost"
219
+ end
220
+ end
221
+ end
222
+ end