tina4ruby 0.5.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +360 -559
  4. data/exe/{tina4 → tina4ruby} +1 -0
  5. data/lib/tina4/ai.rb +312 -0
  6. data/lib/tina4/auth.rb +44 -3
  7. data/lib/tina4/auto_crud.rb +163 -0
  8. data/lib/tina4/cli.rb +242 -77
  9. data/lib/tina4/constants.rb +46 -0
  10. data/lib/tina4/cors.rb +74 -0
  11. data/lib/tina4/database/sqlite3_adapter.rb +139 -0
  12. data/lib/tina4/database.rb +43 -7
  13. data/lib/tina4/debug.rb +4 -79
  14. data/lib/tina4/dev_admin.rb +1162 -0
  15. data/lib/tina4/dev_mailbox.rb +191 -0
  16. data/lib/tina4/dev_reload.rb +9 -9
  17. data/lib/tina4/drivers/firebird_driver.rb +19 -3
  18. data/lib/tina4/drivers/mssql_driver.rb +3 -3
  19. data/lib/tina4/drivers/mysql_driver.rb +4 -4
  20. data/lib/tina4/drivers/postgres_driver.rb +9 -2
  21. data/lib/tina4/drivers/sqlite_driver.rb +1 -1
  22. data/lib/tina4/env.rb +42 -2
  23. data/lib/tina4/error_overlay.rb +252 -0
  24. data/lib/tina4/events.rb +90 -0
  25. data/lib/tina4/field_types.rb +4 -0
  26. data/lib/tina4/frond.rb +1336 -0
  27. data/lib/tina4/gallery/auth/meta.json +1 -0
  28. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
  29. data/lib/tina4/gallery/database/meta.json +1 -0
  30. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
  31. data/lib/tina4/gallery/error-overlay/meta.json +1 -0
  32. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
  33. data/lib/tina4/gallery/orm/meta.json +1 -0
  34. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
  35. data/lib/tina4/gallery/queue/meta.json +1 -0
  36. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
  37. data/lib/tina4/gallery/rest-api/meta.json +1 -0
  38. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
  39. data/lib/tina4/gallery/templates/meta.json +1 -0
  40. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
  41. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
  42. data/lib/tina4/health.rb +39 -0
  43. data/lib/tina4/html_element.rb +148 -0
  44. data/lib/tina4/localization.rb +2 -2
  45. data/lib/tina4/log.rb +203 -0
  46. data/lib/tina4/messenger.rb +484 -0
  47. data/lib/tina4/migration.rb +132 -29
  48. data/lib/tina4/orm.rb +337 -31
  49. data/lib/tina4/public/css/tina4.css +178 -1
  50. data/lib/tina4/public/css/tina4.min.css +1 -2
  51. data/lib/tina4/public/favicon.ico +0 -0
  52. data/lib/tina4/public/images/logo.svg +5 -0
  53. data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
  54. data/lib/tina4/public/js/frond.min.js +420 -0
  55. data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
  56. data/lib/tina4/public/js/tina4.min.js +93 -0
  57. data/lib/tina4/public/swagger/index.html +90 -0
  58. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
  59. data/lib/tina4/queue.rb +40 -4
  60. data/lib/tina4/queue_backends/lite_backend.rb +88 -0
  61. data/lib/tina4/rack_app.rb +314 -23
  62. data/lib/tina4/rate_limiter.rb +123 -0
  63. data/lib/tina4/request.rb +61 -15
  64. data/lib/tina4/response.rb +54 -24
  65. data/lib/tina4/response_cache.rb +134 -0
  66. data/lib/tina4/router.rb +90 -15
  67. data/lib/tina4/scss_compiler.rb +2 -2
  68. data/lib/tina4/seeder.rb +56 -61
  69. data/lib/tina4/service_runner.rb +303 -0
  70. data/lib/tina4/session.rb +85 -0
  71. data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
  72. data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
  73. data/lib/tina4/shutdown.rb +84 -0
  74. data/lib/tina4/sql_translation.rb +295 -0
  75. data/lib/tina4/template.rb +36 -6
  76. data/lib/tina4/templates/base.twig +2 -2
  77. data/lib/tina4/templates/errors/302.twig +14 -0
  78. data/lib/tina4/templates/errors/401.twig +9 -0
  79. data/lib/tina4/templates/errors/403.twig +22 -15
  80. data/lib/tina4/templates/errors/404.twig +22 -15
  81. data/lib/tina4/templates/errors/500.twig +31 -15
  82. data/lib/tina4/templates/errors/502.twig +9 -0
  83. data/lib/tina4/templates/errors/503.twig +12 -0
  84. data/lib/tina4/templates/errors/base.twig +37 -0
  85. data/lib/tina4/version.rb +1 -1
  86. data/lib/tina4/webserver.rb +28 -18
  87. data/lib/tina4.rb +57 -21
  88. metadata +51 -19
  89. data/lib/tina4/public/js/tina4.js +0 -134
  90. data/lib/tina4/public/js/tina4helper.js +0 -387
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ # Context object passed to each service handler, giving it control
5
+ # over its own lifecycle and metadata.
6
+ class ServiceContext
7
+ attr_accessor :running, :last_run, :name, :error_count
8
+
9
+ def initialize(name)
10
+ @running = true
11
+ @last_run = nil
12
+ @name = name
13
+ @error_count = 0
14
+ end
15
+ end
16
+
17
+ # In-process service runner using Ruby threads.
18
+ # Supports cron schedules, simple intervals, and daemon (self-looping) handlers.
19
+ #
20
+ # Tina4::ServiceRunner.register("cleanup", timing: "*/5 * * * *") { |ctx| ... }
21
+ # Tina4::ServiceRunner.register("poller", interval: 10) { |ctx| ... }
22
+ # Tina4::ServiceRunner.register("worker", daemon: true) { |ctx| while ctx.running; ...; end }
23
+ # Tina4::ServiceRunner.start
24
+ #
25
+ class ServiceRunner
26
+ @registry = {} # name => { handler:, options: }
27
+ @threads = {} # name => Thread
28
+ @contexts = {} # name => ServiceContext
29
+ @mutex = Mutex.new
30
+
31
+ class << self
32
+ # ── Registration ──────────────────────────────────────────────────
33
+
34
+ # Register a named service with options and a handler block (or callable).
35
+ #
36
+ # Options:
37
+ # timing: cron expression, e.g. "*/5 * * * *"
38
+ # interval: run every N seconds
39
+ # daemon: boolean — handler manages its own loop
40
+ # max_retries: restart limit on crash (default 3)
41
+ def register(name, handler = nil, options = {}, &block)
42
+ callable = handler || block
43
+ raise ArgumentError, "provide a handler or block for service '#{name}'" unless callable
44
+
45
+ @mutex.synchronize do
46
+ @registry[name.to_s] = { handler: callable, options: options }
47
+ end
48
+ Tina4::Log.debug("Service registered: #{name}")
49
+ end
50
+
51
+ # Auto-discover service files from a directory.
52
+ # Each file should call Tina4.service or Tina4::ServiceRunner.register.
53
+ def discover(service_dir = nil)
54
+ service_dir ||= ENV["TINA4_SERVICE_DIR"] || "src/services"
55
+ full_dir = File.expand_path(service_dir, Tina4.root_dir || Dir.pwd)
56
+ return unless Dir.exist?(full_dir)
57
+
58
+ Dir.glob(File.join(full_dir, "**/*.rb")).sort.each do |file|
59
+ begin
60
+ load file
61
+ Tina4::Log.debug("Service discovered: #{file}")
62
+ rescue => e
63
+ Tina4::Log.error("Failed to load service #{file}: #{e.message}")
64
+ end
65
+ end
66
+ end
67
+
68
+ # ── Lifecycle ─────────────────────────────────────────────────────
69
+
70
+ # Start all registered services, or a specific one by name.
71
+ def start(name = nil)
72
+ targets = if name
73
+ entry = @registry[name.to_s]
74
+ raise KeyError, "service '#{name}' not registered" unless entry
75
+ { name.to_s => entry }
76
+ else
77
+ @registry.dup
78
+ end
79
+
80
+ targets.each do |svc_name, entry|
81
+ next if @threads[svc_name]&.alive?
82
+
83
+ ctx = ServiceContext.new(svc_name)
84
+ @mutex.synchronize { @contexts[svc_name] = ctx }
85
+
86
+ thread = Thread.new { run_loop(svc_name, entry[:handler], entry[:options], ctx) }
87
+ thread.name = "tina4-service-#{svc_name}" if thread.respond_to?(:name=)
88
+ @mutex.synchronize { @threads[svc_name] = thread }
89
+
90
+ Tina4::Log.info("Service started: #{svc_name}")
91
+ end
92
+ end
93
+
94
+ # Stop all running services, or a specific one by name.
95
+ def stop(name = nil)
96
+ targets = if name
97
+ ctx = @contexts[name.to_s]
98
+ ctx ? { name.to_s => ctx } : {}
99
+ else
100
+ @contexts.dup
101
+ end
102
+
103
+ targets.each do |svc_name, ctx|
104
+ ctx.running = false
105
+ Tina4::Log.info("Service stopping: #{svc_name}")
106
+ end
107
+
108
+ # Join threads with a timeout so we don't hang forever
109
+ targets.each_key do |svc_name|
110
+ thread = @threads[svc_name]
111
+ next unless thread
112
+
113
+ thread.join(5)
114
+ @mutex.synchronize do
115
+ @threads.delete(svc_name)
116
+ @contexts.delete(svc_name)
117
+ end
118
+ end
119
+ end
120
+
121
+ # List all registered services with their status.
122
+ def list
123
+ @registry.map do |name, entry|
124
+ ctx = @contexts[name]
125
+ {
126
+ name: name,
127
+ options: entry[:options],
128
+ running: ctx&.running == true && @threads[name]&.alive? == true,
129
+ last_run: ctx&.last_run,
130
+ error_count: ctx&.error_count || 0
131
+ }
132
+ end
133
+ end
134
+
135
+ # Check if a specific service is currently running.
136
+ def running?(name)
137
+ ctx = @contexts[name.to_s]
138
+ ctx&.running == true && @threads[name.to_s]&.alive? == true
139
+ end
140
+
141
+ # Remove all registrations and stop all services. Useful for tests.
142
+ def clear!
143
+ stop
144
+ @mutex.synchronize do
145
+ @registry.clear
146
+ @threads.clear
147
+ @contexts.clear
148
+ end
149
+ end
150
+
151
+ # ── Cron matching ─────────────────────────────────────────────────
152
+
153
+ # Check whether a 5-field cron pattern matches a given Time.
154
+ # Fields: minute hour day_of_month month day_of_week
155
+ def match_cron?(pattern, time = Time.now)
156
+ fields = pattern.strip.split(/\s+/)
157
+ return false unless fields.length == 5
158
+
159
+ minute, hour, dom, month, dow = fields
160
+
161
+ parse_cron_field(minute, time.min, 59) &&
162
+ parse_cron_field(hour, time.hour, 23) &&
163
+ parse_cron_field(dom, time.day, 31) &&
164
+ parse_cron_field(month, time.month, 12) &&
165
+ parse_cron_field(dow, time.wday, 7)
166
+ end
167
+
168
+ private
169
+
170
+ # ── Run loop ──────────────────────────────────────────────────────
171
+
172
+ def run_loop(name, handler, options, ctx)
173
+ max_retries = options.fetch(:max_retries, 3)
174
+ sleep_interval = (ENV["TINA4_SERVICE_SLEEP"] || 1).to_f
175
+
176
+ if options[:daemon]
177
+ run_daemon(name, handler, options, ctx, max_retries)
178
+ elsif options[:timing]
179
+ run_cron(name, handler, options[:timing], ctx, max_retries, sleep_interval)
180
+ elsif options[:interval]
181
+ run_interval(name, handler, options[:interval], ctx, max_retries)
182
+ else
183
+ # One-shot: run handler once
184
+ run_handler(name, handler, ctx)
185
+ end
186
+ rescue => e
187
+ Tina4::Log.error("Service '#{name}' loop crashed: #{e.message}")
188
+ ensure
189
+ ctx.running = false
190
+ end
191
+
192
+ # Daemon mode: handler manages its own loop, we just call it.
193
+ def run_daemon(name, handler, _options, ctx, max_retries)
194
+ retries = 0
195
+ while ctx.running && retries <= max_retries
196
+ begin
197
+ run_handler(name, handler, ctx)
198
+ break # normal exit
199
+ rescue => e
200
+ retries += 1
201
+ ctx.error_count += 1
202
+ Tina4::Log.error("Service '#{name}' daemon crashed (#{retries}/#{max_retries}): #{e.message}")
203
+ break if retries > max_retries
204
+ sleep(1) if ctx.running
205
+ end
206
+ end
207
+ end
208
+
209
+ # Cron mode: check every sleep_interval seconds, fire when pattern matches.
210
+ def run_cron(name, handler, pattern, ctx, max_retries, sleep_interval)
211
+ last_fired_minute = nil
212
+ retries = 0
213
+
214
+ while ctx.running
215
+ now = Time.now
216
+ current_minute = [now.year, now.month, now.day, now.hour, now.min]
217
+
218
+ if match_cron?(pattern, now) && current_minute != last_fired_minute
219
+ last_fired_minute = current_minute
220
+ begin
221
+ run_handler(name, handler, ctx)
222
+ retries = 0
223
+ rescue => e
224
+ retries += 1
225
+ ctx.error_count += 1
226
+ Tina4::Log.error("Service '#{name}' cron failed (#{retries}/#{max_retries}): #{e.message}")
227
+ break if retries > max_retries
228
+ end
229
+ end
230
+
231
+ sleep(sleep_interval) if ctx.running
232
+ end
233
+ end
234
+
235
+ # Interval mode: simple sleep(N) between invocations.
236
+ def run_interval(name, handler, interval, ctx, max_retries)
237
+ retries = 0
238
+
239
+ while ctx.running
240
+ begin
241
+ run_handler(name, handler, ctx)
242
+ retries = 0
243
+ rescue => e
244
+ retries += 1
245
+ ctx.error_count += 1
246
+ Tina4::Log.error("Service '#{name}' interval failed (#{retries}/#{max_retries}): #{e.message}")
247
+ break if retries > max_retries
248
+ end
249
+
250
+ # Sleep in small increments so stop is responsive
251
+ remaining = interval.to_f
252
+ while remaining > 0 && ctx.running
253
+ nap = [remaining, 0.25].min
254
+ sleep(nap)
255
+ remaining -= nap
256
+ end
257
+ end
258
+ end
259
+
260
+ # Execute the handler and update context.
261
+ def run_handler(_name, handler, ctx)
262
+ handler.call(ctx)
263
+ ctx.last_run = Time.now
264
+ end
265
+
266
+ # ── Cron field parsing ────────────────────────────────────────────
267
+
268
+ # Parse a single cron field and check if `current` matches.
269
+ # * — always matches
270
+ # */N — every N (step)
271
+ # 1,5,10 — list of values
272
+ # 1-5 — range
273
+ # N — exact value
274
+ def parse_cron_field(field, current, max)
275
+ return true if field == "*"
276
+
277
+ # Step: */N
278
+ if field.start_with?("*/")
279
+ step = field[2..].to_i
280
+ return false if step <= 0
281
+ return (current % step).zero?
282
+ end
283
+
284
+ # List: 1,5,10
285
+ if field.include?(",")
286
+ values = field.split(",").map(&:to_i)
287
+ return values.include?(current)
288
+ end
289
+
290
+ # Range: 1-5
291
+ if field.include?("-")
292
+ parts = field.split("-")
293
+ low = parts[0].to_i
294
+ high = parts[1].to_i
295
+ return (low..high).include?(current)
296
+ end
297
+
298
+ # Exact value
299
+ field.to_i == current
300
+ end
301
+ end
302
+ end
303
+ end
data/lib/tina4/session.rb CHANGED
@@ -57,6 +57,56 @@ module Tina4
57
57
  @data = {}
58
58
  end
59
59
 
60
+ # Get a session value with optional default
61
+ def get(key, default = nil)
62
+ @data[key.to_s] || default
63
+ end
64
+
65
+ # Set a session value
66
+ def set(key, value)
67
+ @data[key.to_s] = value
68
+ @modified = true
69
+ end
70
+
71
+ # Check if a key exists in the session
72
+ def has?(key)
73
+ @data.key?(key.to_s)
74
+ end
75
+
76
+ # Return all session data
77
+ def all
78
+ @data.dup
79
+ end
80
+
81
+ # Flash data: set a value that is removed after next read.
82
+ # Call with value to set, call without value to get (and remove).
83
+ def flash(key, value = nil)
84
+ flash_key = "_flash_#{key}"
85
+ if value.nil?
86
+ val = @data.delete(flash_key.to_s)
87
+ @modified = true if val
88
+ val
89
+ else
90
+ @data[flash_key.to_s] = value
91
+ @modified = true
92
+ value
93
+ end
94
+ end
95
+
96
+ # Regenerate the session ID while preserving data
97
+ def regenerate
98
+ old_id = @id
99
+ @id = SecureRandom.hex(32)
100
+ @handler.destroy(old_id)
101
+ @modified = true
102
+ end
103
+
104
+ # Garbage collection: remove expired sessions from the handler
105
+ def gc(max_age = nil)
106
+ max_age ||= @options[:max_age]
107
+ @handler.gc(max_age) if @handler.respond_to?(:gc)
108
+ end
109
+
60
110
  def cookie_header
61
111
  "#{@options[:cookie_name]}=#{@id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{@options[:max_age]}"
62
112
  end
@@ -126,6 +176,41 @@ module Tina4
126
176
  @session&.destroy
127
177
  end
128
178
 
179
+ def get(key, default = nil)
180
+ ensure_loaded
181
+ @session.get(key, default)
182
+ end
183
+
184
+ def set(key, value)
185
+ ensure_loaded
186
+ @session.set(key, value)
187
+ end
188
+
189
+ def has?(key)
190
+ ensure_loaded
191
+ @session.has?(key)
192
+ end
193
+
194
+ def all
195
+ ensure_loaded
196
+ @session.all
197
+ end
198
+
199
+ def flash(key, value = nil)
200
+ ensure_loaded
201
+ @session.flash(key, value)
202
+ end
203
+
204
+ def regenerate
205
+ ensure_loaded
206
+ @session.regenerate
207
+ end
208
+
209
+ def gc(max_age = nil)
210
+ ensure_loaded
211
+ @session.gc(max_age)
212
+ end
213
+
129
214
  def cookie_header
130
215
  ensure_loaded
131
216
  @session.cookie_header
@@ -20,7 +20,7 @@ module Tina4
20
20
  rescue LoadError
21
21
  raise "MongoDB session handler requires the 'mongo' gem. Install with: gem install mongo"
22
22
  rescue Mongo::Error => e
23
- Tina4::Debug.error("MongoDB session setup failed: #{e.message}")
23
+ Tina4::Log.error("MongoDB session setup failed: #{e.message}")
24
24
  end
25
25
 
26
26
  def read(session_id)
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Tina4
5
+ module SessionHandlers
6
+ class ValkeyHandler
7
+ def initialize(options = {})
8
+ require "redis"
9
+ @prefix = options[:prefix] || ENV["TINA4_SESSION_VALKEY_PREFIX"] || "tina4:session:"
10
+ @ttl = options[:ttl] || (ENV["TINA4_SESSION_VALKEY_TTL"] ? ENV["TINA4_SESSION_VALKEY_TTL"].to_i : 86400)
11
+ @redis = Redis.new(
12
+ host: options[:host] || ENV["TINA4_SESSION_VALKEY_HOST"] || "localhost",
13
+ port: options[:port] || (ENV["TINA4_SESSION_VALKEY_PORT"] ? ENV["TINA4_SESSION_VALKEY_PORT"].to_i : 6379),
14
+ db: options[:db] || (ENV["TINA4_SESSION_VALKEY_DB"] ? ENV["TINA4_SESSION_VALKEY_DB"].to_i : 0),
15
+ password: options[:password] || ENV["TINA4_SESSION_VALKEY_PASSWORD"]
16
+ )
17
+ rescue LoadError
18
+ raise "Valkey session handler requires the 'redis' gem (Valkey uses the RESP protocol). Install with: gem install redis"
19
+ end
20
+
21
+ def read(session_id)
22
+ data = @redis.get("#{@prefix}#{session_id}")
23
+ return nil unless data
24
+ JSON.parse(data)
25
+ rescue JSON::ParserError
26
+ nil
27
+ end
28
+
29
+ def write(session_id, data)
30
+ key = "#{@prefix}#{session_id}"
31
+ @redis.setex(key, @ttl, JSON.generate(data))
32
+ end
33
+
34
+ def destroy(session_id)
35
+ @redis.del("#{@prefix}#{session_id}")
36
+ end
37
+
38
+ def cleanup
39
+ # Valkey handles TTL automatically (same as Redis)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Shutdown
5
+ DEFAULT_TIMEOUT = 30 # seconds
6
+
7
+ class << self
8
+ attr_reader :in_flight_count
9
+
10
+ def setup(server: nil, timeout: nil)
11
+ @server = server
12
+ @timeout = (timeout || ENV["TINA4_SHUTDOWN_TIMEOUT"] || DEFAULT_TIMEOUT).to_i
13
+ @shutting_down = false
14
+ @mutex = Mutex.new
15
+ @in_flight_count = 0
16
+ @in_flight_cv = ConditionVariable.new
17
+
18
+ install_signal_handlers
19
+ end
20
+
21
+ def shutting_down?
22
+ @shutting_down
23
+ end
24
+
25
+ def track_request
26
+ @mutex.synchronize { @in_flight_count += 1 }
27
+ begin
28
+ yield
29
+ ensure
30
+ @mutex.synchronize do
31
+ @in_flight_count -= 1
32
+ @in_flight_cv.broadcast if @in_flight_count <= 0
33
+ end
34
+ end
35
+ end
36
+
37
+ def initiate_shutdown
38
+ return if @shutting_down
39
+
40
+ @shutting_down = true
41
+ Tina4::Log.info("Shutdown signal received, stopping gracefully...")
42
+
43
+ # Wait for in-flight requests with timeout
44
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
45
+ @mutex.synchronize do
46
+ while @in_flight_count > 0
47
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ if remaining <= 0
49
+ Tina4::Log.warning("Shutdown timeout reached with #{@in_flight_count} requests still in flight")
50
+ break
51
+ end
52
+ @in_flight_cv.wait(@mutex, remaining)
53
+ end
54
+ end
55
+
56
+ # Close database connections
57
+ if Tina4.database
58
+ begin
59
+ Tina4.database.close
60
+ Tina4::Log.info("Database connections closed")
61
+ rescue => e
62
+ Tina4::Log.error("Error closing database: #{e.message}")
63
+ end
64
+ end
65
+
66
+ Tina4::Log.info("Shutdown complete")
67
+
68
+ # Stop the server
69
+ @server&.shutdown if @server.respond_to?(:shutdown)
70
+ end
71
+
72
+ private
73
+
74
+ def install_signal_handlers
75
+ %w[INT TERM].each do |signal|
76
+ Signal.trap(signal) do
77
+ # Signal handlers must be async-signal-safe; use Thread to do real work
78
+ Thread.new { initiate_shutdown }
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end