tina4ruby 3.13.37 → 3.13.38

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.
data/lib/tina4/metrics.rb CHANGED
@@ -353,6 +353,124 @@ module Tina4
353
353
  result
354
354
  end
355
355
 
356
+ # ── Top Offenders (CLI + dashboard) ──────────────────────────
357
+
358
+ # Severity ranking for sorting (higher = more severe).
359
+ SEVERITY_RANK = { "error" => 2, "warn" => 1, "info" => 0 }.freeze
360
+
361
+ # Rank the worst code-quality issues into a single "top offenders" list.
362
+ #
363
+ # Reuses full_analysis (does NOT re-analyze). Each offender is a hash:
364
+ # {"file", "line", "kind", "severity", "score", "detail"}
365
+ #
366
+ # Rules (one offender per matching condition):
367
+ # - function complexity > 10 → kind "complexity"
368
+ # severity "error" if >20 else "warn"; score = complexity
369
+ # - file loc > 500 → kind "large_file" (warn); score = loc/100
370
+ # - file functions > 20 → kind "too_many_functions" (warn); score = functions/4
371
+ # - file maintainability < 40 → kind "low_maintainability"
372
+ # severity "error" if <20 else "warn"; score = (50 - mi)
373
+ # - file has_tests false → kind "untested" (info); score = loc/100
374
+ #
375
+ # Sorted by (severity rank, score) DESCENDING and truncated to `top`.
376
+ #
377
+ # Returns {"offenders" => [...], "summary" => {...}} where summary carries
378
+ # the headline numbers the CLI prints (files_analyzed, total_functions,
379
+ # avg_complexity, avg_maintainability, scan_mode, scan_root, and the total
380
+ # offender count before truncation).
381
+ def self.offenders(root = 'src', top = 20)
382
+ analysis = full_analysis(root)
383
+ if analysis.key?("error")
384
+ return { "offenders" => [], "summary" => { "error" => analysis["error"] } }
385
+ end
386
+
387
+ items = []
388
+
389
+ # Function-level: cyclomatic complexity.
390
+ (analysis["most_complex_functions"] || []).each do |fn|
391
+ cc = fn["complexity"]
392
+ next unless cc > 10
393
+ items << {
394
+ "file" => fn["file"],
395
+ "line" => fn["line"],
396
+ "kind" => "complexity",
397
+ "severity" => cc > 20 ? "error" : "warn",
398
+ "score" => cc.to_f,
399
+ "detail" => "#{fn['name']} — cyclomatic complexity #{cc}"
400
+ }
401
+ end
402
+
403
+ # File-level rules.
404
+ (analysis["file_metrics"] || []).each do |fm|
405
+ path = fm["path"]
406
+ loc = fm["loc"]
407
+ funcs = fm["functions"]
408
+ mi = fm["maintainability"]
409
+
410
+ if loc > 500
411
+ items << {
412
+ "file" => path,
413
+ "line" => 1,
414
+ "kind" => "large_file",
415
+ "severity" => "warn",
416
+ "score" => loc / 100.0,
417
+ "detail" => "#{loc} LOC (max 500)"
418
+ }
419
+ end
420
+
421
+ if funcs > 20
422
+ items << {
423
+ "file" => path,
424
+ "line" => 1,
425
+ "kind" => "too_many_functions",
426
+ "severity" => "warn",
427
+ "score" => funcs / 4.0,
428
+ "detail" => "#{funcs} functions (max 20)"
429
+ }
430
+ end
431
+
432
+ if mi < 40
433
+ items << {
434
+ "file" => path,
435
+ "line" => 1,
436
+ "kind" => "low_maintainability",
437
+ "severity" => mi < 20 ? "error" : "warn",
438
+ "score" => 50 - mi,
439
+ "detail" => "maintainability index #{mi} (min 40)"
440
+ }
441
+ end
442
+
443
+ if fm["has_tests"] == false
444
+ items << {
445
+ "file" => path,
446
+ "line" => 1,
447
+ "kind" => "untested",
448
+ "severity" => "info",
449
+ "score" => loc / 100.0,
450
+ "detail" => "no referencing test"
451
+ }
452
+ end
453
+ end
454
+
455
+ # Sort by (severity rank, score) DESCENDING — stable so insertion order
456
+ # breaks ties deterministically.
457
+ items = items.each_with_index.sort_by do |o, idx|
458
+ [-SEVERITY_RANK[o["severity"]], -o["score"], idx]
459
+ end.map(&:first)
460
+
461
+ summary = {
462
+ "files_analyzed" => analysis["files_analyzed"],
463
+ "total_functions" => analysis["total_functions"],
464
+ "avg_complexity" => analysis["avg_complexity"],
465
+ "avg_maintainability" => analysis["avg_maintainability"],
466
+ "scan_mode" => analysis["scan_mode"],
467
+ "scan_root" => analysis["scan_root"],
468
+ "total_offenders" => items.length
469
+ }
470
+
471
+ { "offenders" => items.first(top), "summary" => summary }
472
+ end
473
+
356
474
  # ── File Detail ─────────────────────────────────────────────
357
475
 
358
476
  def self.file_detail(file_path)
@@ -423,64 +541,137 @@ module Tina4
423
541
 
424
542
  private_class_method
425
543
 
544
+ # Check whether a source file has a test that actually exercises it.
545
+ #
546
+ # PRECISE detection (a bare word-mention is NOT enough — that over-reported
547
+ # badly: `sqlite3_adapter.rb` looked "tested" because some spec merely said
548
+ # "sqlite3_adapter"):
549
+ #
550
+ # 1. Filename — a dedicated `<module>_spec.rb` / `<module>_test.rb` /
551
+ # `test_<module>.rb` for THIS exact module (NOT the parent directory —
552
+ # one `database_spec.rb` must not mark every file under `database/`
553
+ # tested).
554
+ # 2. Require — a spec that actually requires this file: its require path
555
+ # (`require "tina4/database/sqlite"` / `require_relative ".../sqlite"`)
556
+ # matched by the basename of a require target. A constant/class that is
557
+ # genuinely DEFINED in this file (top-level class/module) referenced by
558
+ # a spec also counts.
559
+ #
560
+ # Returns true only on a real, file-specific signal — so the "untested"
561
+ # offenders surfaced by `tina4 metrics` and the dashboard "T" badge are
562
+ # trustworthy. (If you wire real coverage data later, prefer it over this.)
426
563
  def self._has_matching_test(rel_path)
427
564
  require 'set'
428
565
 
429
566
  name = File.basename(rel_path, '.rb')
430
- # Parent directory name (e.g. "database" from "database/sqlite3_adapter.rb")
431
- parent_dir = File.dirname(rel_path)
432
- parent_module = (parent_dir != '.' && !parent_dir.empty?) ? File.basename(parent_dir) : ''
433
-
434
- # Stage 1: Filename matching — name_spec, name_test, test_name patterns
435
- test_dirs = ['spec', 'spec/tina4', 'test', 'tests']
436
- test_dirs.each do |td|
437
- patterns = [
438
- "#{td}/#{name}_spec.rb",
439
- "#{td}/#{name}s_spec.rb",
440
- "#{td}/#{name}_test.rb",
441
- "#{td}/test_#{name}.rb",
442
- ]
443
- # Also check parent-named tests (spec/database_spec.rb covers database/sqlite3_adapter.rb)
444
- if parent_module && !parent_module.empty? && parent_module != name
445
- patterns << "#{td}/#{parent_module}_spec.rb"
446
- patterns << "#{td}/#{parent_module}s_spec.rb"
447
- patterns << "#{td}/#{parent_module}_test.rb"
448
- patterns << "#{td}/test_#{parent_module}.rb"
449
- end
450
- return true if patterns.any? { |p| File.exist?(p) }
451
- end
452
-
453
- # Build a dotted/slashed require path for import matching
454
- # e.g. "lib/tina4/database/sqlite3_adapter.rb" → "tina4/database/sqlite3_adapter"
455
- path_without_ext = rel_path.sub(/\.rb$/, '')
456
- # Strip leading lib/ prefix if present
457
- require_path = path_without_ext.sub(%r{^lib/}, '')
458
-
459
- # Build CamelCase class name from snake_case module name
460
- # e.g. "sqlite3_adapter" "Sqlite3Adapter"
461
- class_name = name.split('_').map(&:capitalize).join
462
-
463
- # Stage 2+3: Content scan — check if any spec/test file references this module
464
- scan_dirs = ['spec', 'test', 'tests']
465
- scan_dirs.each do |td|
466
- next unless Dir.exist?(td)
467
- Dir.glob(File.join(td, '**', '*.rb')).each do |test_file|
468
- content = begin
469
- File.read(test_file, encoding: 'utf-8')
470
- rescue StandardError
471
- next
567
+
568
+ # Require path WITHOUT extension, leading lib/ stripped:
569
+ # "lib/tina4/database/sqlite.rb" -> "tina4/database/sqlite"
570
+ require_path = rel_path.sub(/\.rb$/, '').sub(%r{^lib/}, '')
571
+
572
+ # Constants (classes/modules) DEFINED at the top level of this file — a
573
+ # spec referencing one of them genuinely exercises this file. Names only,
574
+ # distinctive (>3 chars, leading uppercase); bare module-name words and
575
+ # guessed CamelCase are too loose to trust.
576
+ defined_symbols = _defined_constants(rel_path)
577
+
578
+ # Search roots: CWD plus (in framework-fallback mode) the repo root that
579
+ # owns spec/ — walk up from the scan root to find it.
580
+ search_roots = ['.']
581
+ if @last_scan_root && !@last_scan_root.empty?
582
+ scan_root = @last_scan_root
583
+ 5.times do
584
+ if %w[spec test tests].any? { |d| Dir.exist?(File.join(scan_root, d)) }
585
+ search_roots << scan_root
586
+ break
587
+ end
588
+ parent = File.dirname(scan_root)
589
+ break if parent == scan_root
590
+ scan_root = parent
591
+ end
592
+ end
593
+ search_roots.uniq!
594
+
595
+ test_dirs = %w[spec test tests]
596
+
597
+ # Stage 1: a dedicated spec/test FILE named for THIS module (no parent-dir
598
+ # blanket match).
599
+ filename_patterns = [
600
+ "#{name}_spec.rb",
601
+ "#{name}s_spec.rb",
602
+ "#{name}_test.rb",
603
+ "test_#{name}.rb",
604
+ ]
605
+ search_roots.each do |root|
606
+ test_dirs.each do |td|
607
+ filename_patterns.each do |fn|
608
+ return true if File.exist?(File.join(root, td, fn))
609
+ end
610
+ end
611
+ end
612
+
613
+ # Stage 2: a spec that actually REQUIRES this module (precise — matched by
614
+ # the require target's basename / tail of the require path), or references
615
+ # a constant defined in it. NO bare word-of-the-module-name match.
616
+ require_regexps = []
617
+ unless require_path.empty?
618
+ # require "…/<module>" or require_relative "…/<module>" — match the
619
+ # require string ending in this file's require path or basename.
620
+ rp = Regexp.escape(require_path)
621
+ nm = Regexp.escape(name)
622
+ require_regexps << /(?:require|require_relative)\s+['"][^'"]*#{rp}['"]/
623
+ require_regexps << %r{(?:require|require_relative)\s+['"][^'"]*/#{nm}['"]}
624
+ end
625
+ unless defined_symbols.empty?
626
+ sym_alt = defined_symbols.map { |s| Regexp.escape(s) }.join('|')
627
+ require_regexps << /\b(?:#{sym_alt})\b/
628
+ end
629
+
630
+ return false if require_regexps.empty?
631
+
632
+ search_roots.each do |root|
633
+ test_dirs.each do |td|
634
+ dir = File.join(root, td)
635
+ next unless Dir.exist?(dir)
636
+ Dir.glob(File.join(dir, '**', '*.rb')).each do |test_file|
637
+ content = begin
638
+ File.read(test_file, encoding: 'utf-8')
639
+ rescue StandardError
640
+ next
641
+ end
642
+ return true if require_regexps.any? { |re| content.match?(re) }
472
643
  end
473
- # Stage 2: require/require_relative path matching
474
- return true if !require_path.empty? && content.include?(require_path)
475
- # Stage 3: class name or module name mention
476
- return true if content.match?(/\b#{Regexp.escape(class_name)}\b/)
477
- return true if content.match?(/\b#{Regexp.escape(name)}\b/i)
478
644
  end
479
645
  end
480
646
 
481
647
  false
482
648
  end
483
649
 
650
+ # Top-level class/module names defined in the file at rel_path (resolved
651
+ # against the last scan root when present). Distinctive names only:
652
+ # leading-uppercase, longer than 3 chars.
653
+ def self._defined_constants(rel_path)
654
+ src_file = if @last_scan_root && !@last_scan_root.empty? && !File.exist?(rel_path)
655
+ File.join(@last_scan_root, rel_path)
656
+ else
657
+ rel_path
658
+ end
659
+ symbols = Set.new
660
+ content = begin
661
+ File.read(src_file, encoding: 'utf-8')
662
+ rescue StandardError
663
+ return symbols
664
+ end
665
+ content.each_line do |line|
666
+ stripped = line.strip
667
+ m = stripped.match(/\A(?:class|module)\s+([A-Z][A-Za-z0-9_]*)/)
668
+ next unless m
669
+ const = m[1]
670
+ symbols.add(const) if const.length > 3
671
+ end
672
+ symbols
673
+ end
674
+
484
675
  def self._files_hash(root)
485
676
  md5 = Digest::MD5.new
486
677
  root_path = Pathname.new(root)
@@ -41,24 +41,44 @@ module Tina4
41
41
  @global_middleware = []
42
42
  end
43
43
 
44
- # Run all "before" hooks: block-based handlers, then class-based before_* methods.
44
+ # Run all "before" hooks: block-based handlers, then class-based before_*
45
+ # methods (in definition order).
45
46
  #
46
47
  # Signature matches Python/PHP/Node orchestrators: pass the list of
47
48
  # middleware classes explicitly.
48
49
  #
49
- # Returns [request, response] on success, or false to halt the request.
50
+ # M2 visible-but-resilient: every before_* call is wrapped so a THROW
51
+ # never crashes the worker. On a throw the error is LOGGED and the
52
+ # response becomes a clean 500 ({"error":"Internal Server Error",
53
+ # "status":500}), then processing halts (handler skipped) — deterministic,
54
+ # never an unhandled exception. A before_* that sets status >= 400 also
55
+ # halts (the existing 4xx short-circuit). after_* still run on either
56
+ # halt path (see the dispatcher / #run_after docstring).
57
+ #
58
+ # Returns true on success, or false to halt the request (handler skipped).
50
59
  def run_before(middleware_classes, request, response)
51
60
  # 1. Block-based before handlers (pattern-matched)
52
61
  before_handlers.each do |entry|
53
62
  next unless matches_pattern?(request.path, entry[:pattern])
54
- result = entry[:handler].call(request, response)
63
+
64
+ begin
65
+ result = entry[:handler].call(request, response)
66
+ rescue StandardError, ScriptError => error
67
+ middleware_500(response, "before handler", error)
68
+ return false
69
+ end
55
70
  return false if result == false
56
71
  end
57
72
 
58
- # 2. Class-based middleware: call every before_* method
73
+ # 2. Class-based middleware: call every before_* method (definition order)
59
74
  middleware_classes.each do |klass|
60
75
  before_methods_for(klass).each do |method_name|
61
- result = klass.send(method_name, request, response)
76
+ begin
77
+ result = klass.send(method_name, request, response)
78
+ rescue StandardError, ScriptError => error
79
+ middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
80
+ return false
81
+ end
62
82
  # Support returning [request, response] (Python convention) or false to halt
63
83
  if result == false
64
84
  return false
@@ -73,21 +93,42 @@ module Tina4
73
93
  true
74
94
  end
75
95
 
76
- # Run all "after" hooks: block-based handlers, then class-based after_* methods.
96
+ # Run all "after" hooks: block-based handlers, then class-based after_*
97
+ # methods (in definition order).
77
98
  #
78
99
  # Signature matches Python/PHP/Node orchestrators: pass the list of
79
100
  # middleware classes explicitly.
101
+ #
102
+ # AFTER-ON-4xx RULE (M2, documented + consistent across all 4 frameworks):
103
+ # after_* ALWAYS run even when a before_* short-circuited with status >= 400
104
+ # and the handler was skipped — so they can still add headers / logging.
105
+ # The dispatcher calls #run_after unconditionally after the before/handler
106
+ # block (including on the 4xx / throw halt path).
107
+ #
108
+ # M2 — every after_* call is wrapped: a THROW is LOGGED and turns the
109
+ # response into a clean 500, then the REMAINING after_* still run (they
110
+ # may add headers/logging). Never an unhandled crash.
80
111
  def run_after(middleware_classes, request, response)
81
112
  # 1. Block-based after handlers (pattern-matched)
82
113
  after_handlers.each do |entry|
83
114
  next unless matches_pattern?(request.path, entry[:pattern])
84
- entry[:handler].call(request, response)
115
+
116
+ begin
117
+ entry[:handler].call(request, response)
118
+ rescue StandardError, ScriptError => error
119
+ middleware_500(response, "after handler", error)
120
+ end
85
121
  end
86
122
 
87
- # 2. Class-based middleware: call every after_* method
123
+ # 2. Class-based middleware: call every after_* method (definition order)
88
124
  middleware_classes.each do |klass|
89
125
  after_methods_for(klass).each do |method_name|
90
- result = klass.send(method_name, request, response)
126
+ begin
127
+ result = klass.send(method_name, request, response)
128
+ rescue StandardError, ScriptError => error
129
+ middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
130
+ next
131
+ end
91
132
  if result.is_a?(Array) && result.length == 2
92
133
  request, response = result
93
134
  end
@@ -95,8 +136,38 @@ module Tina4
95
136
  end
96
137
  end
97
138
 
139
+ # Deterministic clean 500 for a middleware that threw. Logs the cause
140
+ # (NEVER silent) then sets the response to the canonical error shape —
141
+ # byte-identical to the Python master ({"error":"Internal Server Error",
142
+ # "status":500} + status 500). Returns the response for chaining.
143
+ def middleware_500(response, label, error)
144
+ begin
145
+ Tina4::Log.error(
146
+ "Middleware #{label} raised #{error.class.name}: #{error.message}"
147
+ )
148
+ rescue StandardError
149
+ begin
150
+ $stderr.puts("Middleware #{label} raised #{error.class.name}: #{error.message}")
151
+ $stderr.flush
152
+ rescue StandardError
153
+ # never let logging break the worker
154
+ end
155
+ end
156
+ response.json({ error: "Internal Server Error", status: 500 }, 500)
157
+ end
158
+
98
159
  private
99
160
 
161
+ # Human-readable label for a middleware (class name, or the class of an
162
+ # instance) used in the logged 500 message.
163
+ def class_label(klass)
164
+ if klass.is_a?(Class) || klass.is_a?(Module)
165
+ klass.name || klass.to_s
166
+ else
167
+ klass.class.name || klass.class.to_s
168
+ end
169
+ end
170
+
100
171
  def matches_pattern?(path, pattern)
101
172
  return true if pattern.nil?
102
173
  case pattern
@@ -109,14 +180,66 @@ module Tina4
109
180
  end
110
181
  end
111
182
 
112
- # Collect all public class methods matching before_*
183
+ # Collect all class methods matching before_* in DEFINITION order.
113
184
  def before_methods_for(klass)
114
- klass.methods(false).select { |m| m.to_s.start_with?("before_") }.sort
185
+ discover_methods(klass, "before_")
115
186
  end
116
187
 
117
- # Collect all public class methods matching after_*
188
+ # Collect all class methods matching after_* in DEFINITION order.
118
189
  def after_methods_for(klass)
119
- klass.methods(false).select { |m| m.to_s.start_with?("after_") }.sort
190
+ discover_methods(klass, "after_")
191
+ end
192
+
193
+ # ----------------------------------------------------------------------
194
+ # MIDDLEWARE ORDERING (M1) — within a class, before_*/after_* methods run
195
+ # in SOURCE-DEFINITION order, NOT alphabetical. Cross-class order is the
196
+ # natural iteration of the registered middleware list (registration
197
+ # order). before_* run before the handler, after_* after.
198
+ #
199
+ # WHY source line numbers, not instance_methods(false): in Ruby/PRISM
200
+ # `instance_methods(false)` is NOT a reliable definition-order report —
201
+ # once a method NAME (symbol) has been defined on any other class first,
202
+ # that name can sort ahead in a later class's list. So we sort the
203
+ # matching methods by their `source_location` line number, which IS the
204
+ # true source-definition order and is immune to the symbol-table quirk.
205
+ # (Methods with no source_location — e.g. C-defined — sort to the front
206
+ # deterministically by name.) We walk the ancestry base→derived so
207
+ # inherited middleware methods run before a subclass's own, de-duping
208
+ # overrides to their first (base) position. Mirrors the Python master's
209
+ # Middleware._discover_methods MRO walk (which leans on __dict__ insertion
210
+ # order — the equivalent of source-definition order).
211
+ def discover_methods(klass, prefix)
212
+ target = klass.is_a?(Class) || klass.is_a?(Module) ? klass.singleton_class : klass.class
213
+ seen = {}
214
+ names = []
215
+ target.ancestors.reverse_each do |ancestor|
216
+ matched = begin
217
+ ancestor.instance_methods(false).select do |name|
218
+ name.to_s.start_with?(prefix) &&
219
+ !seen.key?(name) &&
220
+ klass.respond_to?(name)
221
+ end
222
+ rescue StandardError
223
+ []
224
+ end
225
+
226
+ ordered = matched.sort_by.with_index do |name, idx|
227
+ line = begin
228
+ loc = ancestor.instance_method(name).source_location
229
+ loc ? loc[1] : -1
230
+ rescue StandardError
231
+ -1
232
+ end
233
+ # Tie-break on the symbol-table index so the result is total/stable.
234
+ [line, idx]
235
+ end
236
+
237
+ ordered.each do |name|
238
+ seen[name] = true
239
+ names << name
240
+ end
241
+ end
242
+ names
120
243
  end
121
244
  end
122
245
  end
@@ -356,10 +356,12 @@ module Tina4
356
356
  Tina4::Log.info("Migration #{File.basename(file)}: #{skip_reason}")
357
357
  next
358
358
  end
359
- result = @db.execute(stmt)
360
- if result == false
361
- raise RuntimeError, @db.get_error || "SQL execution failed: #{stmt}"
362
- end
359
+ # db.execute() now RAISES on a SQL error (it no longer returns false),
360
+ # so a bad statement throws straight up to run_migration's rescue, which
361
+ # records the migration as failed and surfaces the error. The old
362
+ # "if result == false: raise" check is dead — a plain execute carries
363
+ # the same failure semantics.
364
+ @db.execute(stmt)
363
365
  end
364
366
  end
365
367
 
data/lib/tina4/orm.rb CHANGED
@@ -425,17 +425,20 @@ module Tina4
425
425
  translator_engine = %w[postgres postgresql].include?(engine) ? "postgresql" : engine
426
426
  sql = SQLTranslator.auto_increment_syntax(sql, translator_engine)
427
427
 
428
- # Don't claim success when the DDL failed. db.execute() swallows the
429
- # driver error into get_error() and returns false, so a bad type (or
430
- # any DDL error) used to leave create_table returning true while no
431
- # table was actually created.
432
- ok = db.execute(sql)
433
- db.commit
434
- if ok == false
435
- Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error}", { sql: sql })
436
- return false
428
+ # Don't claim success when the DDL failed. db.execute() now RAISES on a
429
+ # SQL error (it no longer swallows it into get_error() and returns
430
+ # false), so a bad type (or any DDL error) surfaces here as an
431
+ # exception. create_table keeps its documented bool contract: catch the
432
+ # raise, log the cause, and return false so callers that test the return
433
+ # still see a clean failure instead of a thrown error.
434
+ begin
435
+ db.execute(sql)
436
+ db.commit
437
+ true
438
+ rescue => e
439
+ Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error || e.message}", { sql: sql })
440
+ false
437
441
  end
438
- true
439
442
  end
440
443
 
441
444
  def scope(name, filter_sql, params = [])
@@ -351,9 +351,12 @@ module Tina4
351
351
  env["tina4.request"] = request # Store for session save after response
352
352
  response = Tina4::Response.new
353
353
 
354
- # Run global middleware (block-based + class-based before_* methods)
354
+ # Run global middleware (block-based + class-based before_* methods).
355
+ # M2 — AFTER-ON-4xx RULE: when a before_* short-circuits (4xx/skip) or
356
+ # throws (clean 500), the after-pass STILL runs so after_* can add
357
+ # headers/logging — consistent across all 4 frameworks.
355
358
  unless Tina4::Middleware.run_before(Tina4::Middleware.global_middleware, request, response)
356
- # Middleware halted the request -- return whatever response was set
359
+ Tina4::Middleware.run_after(Tina4::Middleware.global_middleware, request, response)
357
360
  return response.to_rack
358
361
  end
359
362
 
@@ -885,18 +888,23 @@ module Tina4
885
888
  # Wire the route handler into the WebSocket engine events
886
889
  handler = ws_route.handler
887
890
 
888
- # Create a dedicated WebSocket engine for this route so handlers stay isolated
891
+ # Create a dedicated WebSocket *event* engine for this route so each
892
+ # upgrade keeps its own isolated open/message/close handlers (the engine's
893
+ # on() appends handlers, so a single shared event engine would cross-wire
894
+ # routes).
889
895
  ws = Tina4::WebSocket.new
890
896
 
891
- # The dev-reload channel is held by a process-wide shared manager so a
892
- # broadcast from POST /__dev/api/reload reaches every browser, not just
893
- # the connections of this one isolated per-socket engine. Mirrors
894
- # Python's single _ws_manager keyed on the /__dev_reload path.
897
+ # The connection itself is OWNED by a process-wide shared manager so that
898
+ # broadcasts, rooms, the multi-instance backplane and the idle reaper span
899
+ # every route's connections not just this one per-socket event engine.
900
+ # Mirrors Python's single WebSocketManager. The dev-reload channel uses its
901
+ # own shared manager (Tina4::DevReload) so POST /__dev/api/reload reaches
902
+ # every browser.
895
903
  dev_reload = ws_route.path == "/__dev_reload"
904
+ manager = dev_reload ? Tina4::DevReload.manager : @websocket_engine
896
905
 
897
906
  ws.on(:open) do |connection|
898
907
  connection.params = ws_params
899
- Tina4::DevReload.add(connection) if dev_reload
900
908
  handler.call(connection, :open, nil)
901
909
  end
902
910
 
@@ -905,7 +913,6 @@ module Tina4
905
913
  end
906
914
 
907
915
  ws.on(:close) do |connection|
908
- Tina4::DevReload.remove(connection) if dev_reload
909
916
  handler.call(connection, :close, nil)
910
917
  end
911
918
 
@@ -913,7 +920,7 @@ module Tina4
913
920
  Tina4::Log.error("WebSocket error on #{ws_route.path}: #{error.message}")
914
921
  end
915
922
 
916
- ws.handle_upgrade(env, socket)
923
+ ws.handle_upgrade(env, socket, manager: manager)
917
924
 
918
925
  # Return async response (-1 signals Rack the response is handled via hijack)
919
926
  [-1, {}, []]