fiber_audit 0.2.1 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f76c5789a7f5490e2e568cb6ea912520ae1350cc5a0de15053b3b30fd271e10d
4
- data.tar.gz: aac0a9b350682e2c1abb167057ab4ea9ba912f277efa8818dd7269cee8d71058
3
+ metadata.gz: 64afd3fe0ced532129e0d6e55508edca15e7a613ca30c064a61b9a868b155488
4
+ data.tar.gz: a4db073387d08a250c8c79e95251ebc39ddb414a2a830d5ddb91c85d673d9989
5
5
  SHA512:
6
- metadata.gz: 9d1cbe6bca9cc0363a483f9c2902a13c0389fc1fc0b833023d923342c23e8ea01b884e71233ddc2ba5b6f802cb83f7af6c5bb17ae67ef1b01d96958517ea5bca
7
- data.tar.gz: f14db18581138d051937fbb788b4c3d6a8ec8970647fa82612d5911d2809b55918adfd27e02ec790500e86ae80c8cdeec112a60515d8cc22eb1f52da07ffcf89
6
+ metadata.gz: a632c52483c6b266bbbe1d99ca7e5dfb3c35d5556d364da7bd3adaab44308c551eda6db22d77c8b15871158386015ac2fe9c0fe69d0336a31d4094df5f4d5c4d
7
+ data.tar.gz: 638caf18b5c0273abe010457b74e33bc79082a857d3196c6020394b8d771d5a3479d27d83f1cc97abdcc4ace7dbc6eee01285fa7013b8a59019ac4bc53646a2b
data/ARCHITECTURE.md CHANGED
@@ -20,13 +20,10 @@ produces hypotheses, while observational runtime sessions provide bounded eviden
20
20
  without establishing complete coverage. FiberAudit never claims unconditional
21
21
  `PASS`.
22
22
 
23
- > **Repository status:** v0.2.1 includes the v0.1.0 static pipeline end to end:
24
- > project discovery, configuration, semantic and syntax analysis, execution
25
- > contexts, FA1001–FA1007, suppressions, status derivation, text/JSON reports,
26
- > and the CLI. The v0.2 runtime contracts, bounded JSONL recorder, explicit
27
- > child-process boot, lifecycle, supervising command, bounded scheduler
28
- > watchdog, targeted FA1001–FA1007 operation probes, and Rails runtime execution
29
- > contexts are implemented; static/runtime correlation remains future work.
23
+ > **Repository status:** v0.3.0 includes the static pipeline end to end,
24
+ > observational runtime probes, propagated Rails execution context,
25
+ > scheduler-capability snapshots, and bounded operation/stall overlap events.
26
+ > Combined static/runtime reporting remains future work.
30
27
 
31
28
  ## 2. Scope
32
29
 
@@ -60,8 +57,8 @@ Current code and specs define implementation truth. This document records the
60
57
  supported architecture and dependency boundaries; README and CHANGELOG record
61
58
  the user-facing release contract.
62
59
 
63
- The corrected platform target is Ruby `>= 3.3` with CI configured for Ruby 3.3
64
- and 3.4. Ruby 3.2 is excluded because it is end-of-life.
60
+ The platform target is Ruby `>= 3.3` with CI configured for Ruby 3.3, 3.4,
61
+ and 4.0. Ruby 3.2 is excluded because it is end-of-life.
65
62
 
66
63
  ## 4. Architectural Principles
67
64
 
@@ -363,17 +360,17 @@ explicit and can lower confidence.
363
360
  Rules consume FiberAudit `CallSite` values and emit `Finding` values. They do
364
361
  not parse files and do not access Rubydex directly.
365
362
 
366
- Shipped v0.1.0 rules:
363
+ Shipped rules:
367
364
 
368
365
  | ID | Concern | Default severity |
369
366
  |---|---|---:|
370
- | FA1001 | Blocking subprocess operations | high |
371
- | FA1002 | `Thread#join` / `Thread#value` | high |
372
- | FA1003 | Blocking synchronization | medium |
373
- | FA1004 | Thread-local request state | medium/high by operation |
374
- | FA1005 | Explicit `IO.select` | medium |
375
- | FA1006 | Direct socket creation | medium |
376
- | FA1007 | `Net::HTTP` in request-like contexts | high |
367
+ | FA1001 | Subprocess lifecycle and process-wait cooperation | info/medium |
368
+ | FA1002 | Thread-wait scheduler coordination | low |
369
+ | FA1003 | Synchronization scheduler coordination | low/info |
370
+ | FA1004 | True Thread variables shared across sibling Fibers | high |
371
+ | FA1005 | `IO.select` scheduler capability requirement | medium |
372
+ | FA1006 | Socket/DNS/I/O scheduler cooperation | low |
373
+ | FA1007 | HTTP scheduler cooperation in request-like contexts | medium |
377
374
 
378
375
  A rule registry owns registration, enumeration, configuration enablement, and
379
376
  metadata used by `list-rules` and `explain`.
@@ -595,7 +592,8 @@ spec/fixtures/apps/rails_contexts
595
592
  spec/fixtures/reports/rails_blockers_v0.1.json
596
593
  ```
597
594
 
598
- CI is configured to run linting, specs, and gem packaging on Ruby 3.3 and 3.4.
595
+ CI is configured to run linting, specs, and gem packaging on Ruby 3.3, 3.4,
596
+ and 4.0.
599
597
  The workflow configuration does not itself prove that remote CI has passed.
600
598
 
601
599
  ## 13. Runtime Architecture Beyond v0.1.0
@@ -666,11 +664,11 @@ stall, and only then closes the recorder. Fork rebinding discards inherited prob
666
664
  and watchdog references before touching their locks and creates process-local
667
665
  replacements.
668
666
 
669
- Stage 6 adds Rails execution context detection. A bounded, PID-aware fiber-local
670
- context stack tracks the current execution context during probe observations.
671
- The fiber-local stack uses `Fiber.current` instance variables to avoid thread-local
672
- key visibility, validates against `Context::ALL`, enforces `MAX_DEPTH = 32`, and
673
- resets on fork. A process-local `RailsIntegration` class hooks into Rails boundaries
667
+ Rails execution context detection uses a bounded immutable frame chain in Ruby
668
+ Fiber storage. Child Fibers inherit the current logical context snapshot, while
669
+ child overrides and `clear!` remain local to that Fiber. Frames carry PID and
670
+ Thread ownership, validate against `Context::ALL`, enforce `MAX_DEPTH = 32`, and
671
+ reset on fork. A process-local `RailsIntegration` class hooks into Rails boundaries
674
672
  via `Module#prepend`: Rack middleware (`:middleware`), `ActionController::Metal#process_action`
675
673
  (`:request`), `ActiveJob::Base#perform_now` (`:job`), and `ActionCable::Channel::Base#dispatch_action`
676
674
  (`:websocket`). Wrappers consult the active integration before setting context and become
@@ -680,7 +678,11 @@ even after runtime boot. Probe observations snapshot the context once at start a
680
678
  propagate it through active operations and events. Lifecycle wires context store and
681
679
  Rails integration ownership, shutdown deactivates Rails integration before probes,
682
680
  and fork rebinding resets context and rebuilds integration. JSONL schema 1.0 and
683
- privacy requirements are preserved; no new schema fields are added.
681
+ privacy requirements are preserved; no new schema fields are added. Runtime
682
+ operation events also record scheduler presence, blocking-Fiber state, and
683
+ optional hook support. Watchdog stalls emit bounded
684
+ `scheduler_stall_operation_overlap` events for operations active on the same
685
+ Thread. These events establish temporal overlap, not causality.
684
686
 
685
687
  Loading the gem normally performs no instrumentation, fibers, threads, or file
686
688
  I/O. The observer is activated only by explicit runtime boot. A native operation
data/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.0 (2026-08-12)
6
+
7
+ ### Changed
8
+
9
+ - Reframed static rules as scheduler-cooperation requirements. Advisory rules no
10
+ longer escalate solely because they appear in request-like execution contexts.
11
+ - Split FA1001 under its existing rule ID into subprocess creation, replacement,
12
+ waiting, detach, and stream lifecycle semantics; added Process `spawn`, `exec`,
13
+ `wait`, `wait2`, `waitpid`, `waitpid2`, and `Process::Status.wait` coverage.
14
+ - Corrected FA1004 to report only true Thread variables. `Thread.current[]` and
15
+ `Thread.current[]=` are Fiber-local and are no longer static or runtime findings.
16
+ - Propagated Rails/runtime execution context into child Fibers through immutable
17
+ Ruby Fiber storage, with explicit current-Fiber `clear!`/`reset!` behavior.
18
+ - Added scheduler presence, blocking-Fiber state, and optional scheduler-hook
19
+ capability measurements to targeted operation events.
20
+ - Added bounded `scheduler_stall_operation_overlap` JSONL 1.0 events. They record
21
+ temporal overlap between a watchdog stall and active operations, not causality.
22
+ - Reconciled scheduler observer state only after `Fiber.set_scheduler` accepts a
23
+ change, so rejected replacement attempts preserve the previous observation.
24
+ - Added Ruby 4.0 to the required CI matrix and executable local scheduler-semantic
25
+ reproductions under `script/scheduler-semantics`, including Ruby 4's
26
+ `fiber_interrupt` scheduler protocol hook.
27
+
5
28
  ## 0.2.1 (2026-08-12)
6
29
 
7
30
  ### Fixed
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # FiberAudit
2
2
 
3
- FiberAudit audits Ruby and Rails code for operations that can block the thread
4
- running a Fiber scheduler. Version 0.2.1 includes static analysis and an explicit,
5
- observational runtime audit.
3
+ FiberAudit audits Ruby and Rails code for operations that require cooperation
4
+ from a Fiber scheduler. Version 0.3.0 includes static analysis and an explicit,
5
+ observational runtime audit with scheduler-capability and stall-overlap evidence.
6
6
 
7
7
  > **Safety disclaimer:** FiberAudit does not prove that an application is
8
8
  > fiber-safe. Static findings are hypotheses, and absence of runtime events does
@@ -10,7 +10,7 @@ observational runtime audit.
10
10
 
11
11
  ## Requirements and installation
12
12
 
13
- FiberAudit v0.2.1 supports Ruby 3.3 and 3.4.
13
+ FiberAudit v0.3.0 supports Ruby 3.3, 3.4, and 4.0.
14
14
 
15
15
  ```sh
16
16
  gem install fiber_audit
@@ -52,13 +52,13 @@ Explicit `--format` always wins.
52
52
 
53
53
  | ID | Detects | Default severity |
54
54
  |---|---|---|
55
- | FA1001 | Blocking subprocess operations | high |
56
- | FA1002 | `Thread#join` and `Thread#value` | high |
57
- | FA1003 | Thread-oriented synchronization | medium |
58
- | FA1004 | Thread-local state access | high/medium |
59
- | FA1005 | Explicit `IO.select`/`Kernel.select` | medium |
60
- | FA1006 | Direct socket construction | medium |
61
- | FA1007 | Synchronous HTTP in request-like contexts | high |
55
+ | FA1001 | Subprocess creation, replacement, waiting, and streams | info/medium |
56
+ | FA1002 | Thread-wait scheduler coordination | low |
57
+ | FA1003 | Synchronization scheduler coordination | low/info |
58
+ | FA1004 | True Thread-variable state shared by sibling Fibers | high |
59
+ | FA1005 | `IO.select` scheduler capability requirement | medium |
60
+ | FA1006 | Socket/DNS/I/O scheduler cooperation | low |
61
+ | FA1007 | HTTP scheduler cooperation in request-like contexts | medium |
62
62
 
63
63
  Use `fiber-audit explain <RULE_ID>` for exact targets and remediation.
64
64
 
@@ -82,13 +82,17 @@ Libraries such as Open3, Monitor, Socket, Net::HTTP, and OpenURI may be loaded
82
82
  after runtime boot; FiberAudit rescans only these known targets after `require`.
83
83
 
84
84
  Rails execution contexts (`:request`, `:middleware`, `:job`, `:websocket`) are
85
- captured automatically when Rails integration is active. A bounded, PID-aware
86
- fiber-local context stack tracks the current execution context during probe
87
- observations. Rails boundaries are wrapped via prepend hooks that become inert
85
+ captured automatically when Rails integration is active. A bounded, PID- and
86
+ Thread-aware immutable context chain uses inheritable Ruby Fiber storage, so
87
+ child Fibers receive a snapshot of their parent's logical context. FiberAudit's
88
+ `ExecutionContext.clear!` explicitly detaches the current Fiber without allowing
89
+ enclosing scopes to restore stale context. Rails boundaries use prepend hooks that become inert
88
90
  after deactivation or fork, preserving application semantics without interfering
89
91
  with normal Rails operation. The integration supports late loading: hooks are
90
92
  installed when Rails components become available, even after runtime boot.
91
- Static/runtime correlation remains future work.
93
+ Raw runtime JSONL now records bounded `scheduler_stall_operation_overlap`
94
+ events linking active operation sequences to watchdog stalls. This establishes
95
+ temporal overlap, not causality. Combined static/runtime reporting remains future work.
92
96
 
93
97
  The scheduler watchdog records one bounded start/completion pair when its
94
98
  scheduler-owned heartbeat stops progressing past the configured threshold.
@@ -190,7 +194,7 @@ Missing reasons and invalid configuration return exit code 2.
190
194
  - `PASS_WITH_WARNINGS` — only low or informational findings.
191
195
  - `NO_FINDINGS` — no findings at the configured threshold.
192
196
 
193
- FiberAudit never emits unconditional `PASS` in v0.2.1.
197
+ FiberAudit never emits unconditional `PASS` in v0.3.0.
194
198
 
195
199
  ## Exit codes
196
200
 
@@ -199,10 +203,30 @@ FiberAudit never emits unconditional `PASS` in v0.2.1.
199
203
  | 0 | No active finding at or above the configured threshold |
200
204
  | 1 | One or more active findings at or above the threshold |
201
205
  | 2 | Invalid options, configuration, analysis, or report output |
202
- | 3 | Reserved; never emitted by v0.2.1 |
206
+ | 3 | Reserved; never emitted by v0.3.0 |
203
207
 
204
208
  Source parse errors are included in report data while analysis continues on
205
209
  other files.
206
210
 
211
+ ## Development and semantic verification
212
+
213
+ ```sh
214
+ bundle exec rspec
215
+ bundle exec rubocop
216
+ bundle exec ruby script/scheduler-semantics
217
+ gem build fiber_audit.gemspec
218
+ bundle exec rake release:sanity
219
+ ```
220
+
221
+ The semantic probe uses only local Threads, pipes, and child processes. It checks
222
+ scheduler coordination, Fiber-storage inheritance, Thread/Fiber state semantics,
223
+ and rejected scheduler replacement against each supported Ruby in CI.
224
+
225
+ Relevant Ruby contracts:
226
+
227
+ - [Fiber and inheritable storage](https://docs.ruby-lang.org/en/3.4/Fiber.html)
228
+ - [Fiber::Scheduler hooks](https://docs.ruby-lang.org/en/3.4/Fiber/Scheduler.html)
229
+ - [Thread Fiber-local and Thread-wide storage](https://docs.ruby-lang.org/en/3.4/Thread.html#method-i-5B-5D)
230
+
207
231
  See [ARCHITECTURE.md](ARCHITECTURE.md) for implementation boundaries, runtime
208
- architecture, and explicitly deferred correlation work.
232
+ architecture, raw stall-overlap evidence, and deferred combined reporting.
@@ -274,12 +274,25 @@ module FiberAudit
274
274
  stdout.puts "Description: #{rule_class.description}"
275
275
  stdout.puts 'Targets:'
276
276
  rule_targets(rule_class).each { |target| stdout.puts " - #{target}" }
277
- stdout.puts "Remediation: #{rule_class.const_get(:REMEDIATION)}"
277
+ print_rule_remediations(rule_class, stdout)
278
278
  0
279
279
  end
280
280
 
281
+ def print_rule_remediations(rule_class, stdout)
282
+ values = if rule_class.const_defined?(:REMEDIATION, false)
283
+ [rule_class.const_get(:REMEDIATION)]
284
+ elsif rule_class.const_defined?(:CATEGORY_METADATA, false)
285
+ rule_class.const_get(:CATEGORY_METADATA).values.map { |metadata| metadata.fetch(:remediation) }.uniq
286
+ else
287
+ []
288
+ end
289
+ values.each_with_index do |remediation, index|
290
+ stdout.puts "#{index.zero? ? 'Remediation:' : ' '} #{remediation}"
291
+ end
292
+ end
293
+
281
294
  def rule_title(rule_class)
282
- return 'Blocking subprocess call' if rule_class.id == 'FA1001'
295
+ return 'Subprocess lifecycle operations' if rule_class.id == 'FA1001'
283
296
 
284
297
  %i[TITLE RULE_TITLE].each do |name|
285
298
  return rule_class.const_get(name) if rule_class.const_defined?(name, false)
@@ -296,8 +309,7 @@ module FiberAudit
296
309
  when 'FA1003'
297
310
  expand_target_map(rule_class::TARGETS, '#')
298
311
  when 'FA1004'
299
- rule_class::THREAD_VARIABLE_METHODS.map { |method| "Thread##{method}" } +
300
- rule_class::INDEX_METHODS.map { |method| "Thread.current.#{method}" }
312
+ rule_class::THREAD_VARIABLE_METHODS.map { |method| "Thread##{method}" }
301
313
  when 'FA1005'
302
314
  expand_target_map(rule_class::TARGETS.transform_values { |method| [method] }, '.')
303
315
  when 'FA1006'
@@ -7,7 +7,8 @@ module FiberAudit
7
7
  'Kernel' => %i[system exec spawn].freeze,
8
8
  'Open3' => %i[capture2 capture2e capture3 pipeline].freeze,
9
9
  'IO' => %i[popen].freeze,
10
- 'Process' => %i[waitall detach].freeze
10
+ 'Process' => %i[spawn exec wait wait2 waitpid waitpid2 waitall detach].freeze,
11
+ 'Process::Status' => %i[wait].freeze
11
12
  }.freeze
12
13
  FA1001_KERNEL_METHODS = %i[system exec spawn].freeze
13
14
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'event'
4
4
  require_relative 'location'
5
+ require_relative 'scheduler_snapshot'
5
6
  require_relative 'validation'
6
7
 
7
8
  module FiberAudit
@@ -19,7 +20,8 @@ module FiberAudit
19
20
  :operation,
20
21
  :location,
21
22
  :execution_context,
22
- :started_monotonic_ns
23
+ :started_monotonic_ns,
24
+ :scheduler_snapshot
23
25
  )
24
26
 
25
27
  def initialize(pid_source: Process.method(:pid), capacity: MAX_ENTRIES, snapshot_limit: MAX_SNAPSHOT)
@@ -38,7 +40,8 @@ module FiberAudit
38
40
  location: nil,
39
41
  execution_context: :unknown,
40
42
  thread: Thread.current,
41
- fiber: Fiber.current
43
+ fiber: Fiber.current,
44
+ scheduler_snapshot: nil
42
45
  )
43
46
  ensure_current_process!
44
47
  values = normalize_entry(
@@ -47,7 +50,8 @@ module FiberAudit
47
50
  execution_context: execution_context,
48
51
  monotonic_ns: monotonic_ns,
49
52
  thread: thread,
50
- fiber: fiber
53
+ fiber: fiber,
54
+ scheduler_snapshot: scheduler_snapshot
51
55
  )
52
56
 
53
57
  @mutex.synchronize do
@@ -90,7 +94,7 @@ module FiberAudit
90
94
 
91
95
  private
92
96
 
93
- def normalize_entry(operation:, location:, execution_context:, monotonic_ns:, thread:, fiber:)
97
+ def normalize_entry(operation:, location:, execution_context:, monotonic_ns:, thread:, fiber:, scheduler_snapshot: nil)
94
98
  canonical_operation = Validation.operation(operation)
95
99
  unless location.nil? || location.is_a?(Location)
96
100
  raise RuntimeContractError, 'location must be a FiberAudit::Runtime::Location or nil'
@@ -103,16 +107,26 @@ module FiberAudit
103
107
  raise RuntimeContractError, 'thread and fiber identities are invalid'
104
108
  end
105
109
 
110
+ normalized_snapshot = normalize_scheduler_snapshot(scheduler_snapshot)
111
+
106
112
  {
107
113
  thread_id: Validation.integer(thread.object_id, 'thread_id'),
108
114
  fiber_id: Validation.integer(fiber.object_id, 'fiber_id'),
109
115
  operation: canonical_operation,
110
116
  location: location,
111
117
  execution_context: normalized_context,
112
- started_monotonic_ns: Validation.integer(monotonic_ns, 'monotonic_ns')
118
+ started_monotonic_ns: Validation.integer(monotonic_ns, 'monotonic_ns'),
119
+ scheduler_snapshot: normalized_snapshot
113
120
  }
114
121
  end
115
122
 
123
+ def normalize_scheduler_snapshot(value)
124
+ return value if value.nil?
125
+ return value if value.is_a?(SchedulerSnapshot)
126
+
127
+ raise RuntimeContractError, 'scheduler_snapshot must be a FiberAudit::Runtime::SchedulerSnapshot or nil'
128
+ end
129
+
116
130
  def ensure_current_process!
117
131
  pid = current_pid
118
132
  reset_for_process!(pid) unless pid == @owner_pid
@@ -4,73 +4,85 @@ require_relative '../execution_context'
4
4
 
5
5
  module FiberAudit
6
6
  module Runtime
7
- # Fiber-local execution context stack.
8
- # Uses fiber instance variables for isolation without Thread#[] visibility.
9
- # PID-aware to handle fork correctly.
7
+ # Fiber-local execution context with propagation to child fibers.
8
+ # Uses Ruby Fiber storage (Fiber[]) for fiber-local state that is
9
+ # inherited by child fibers at creation, enabling automatic context
10
+ # propagation across Fiber boundaries.
11
+ #
12
+ # Frames are immutable (frozen Data objects) forming a linked list.
13
+ # Each +with+ call creates a new frame pointing to the parent frame.
14
+ #
15
+ # Semantics:
16
+ # - Child fibers inherit the parent fiber's current context at creation
17
+ # - Child fiber overrides do not alter parent context
18
+ # - +clear!+ removes context for the current fiber only
19
+ # - +clear!+ inside nested +with+ is not undone by the enclosing ensure
20
+ # (ensure restores only when its own frame is still the active one)
21
+ # - Thread isolation is maintained even though Ruby copies Fiber storage to a
22
+ # newly created Thread's root Fiber
23
+ # - PID mismatch (after fork) is treated as empty context
24
+ # - MAX_DEPTH overflow exposes :unknown rather than stale outer context
10
25
  module ExecutionContext
11
26
  MAX_DEPTH = 32
12
- IVAR_KEY = :@__fiber_audit_execution_context__
13
- IVAR_PID_KEY = :@__fiber_audit_execution_context_pid__
27
+ FRAME_KEY = :__fiber_audit_execution_context_frame__
28
+
29
+ # Immutable frame in the context chain. Process and Thread ownership keep
30
+ # inherited storage from crossing fork or Thread boundaries.
31
+ Frame = Data.define(:context, :parent, :depth, :pid, :thread_id)
32
+ private_constant :Frame
14
33
 
15
34
  class << self
16
35
  def current
17
- state = current_state
18
- return Context::UNKNOWN unless state
19
-
20
- state[:stack].last || Context::UNKNOWN
36
+ frame = current_frame
37
+ frame ? frame.context : Context::UNKNOWN
21
38
  end
22
39
 
23
40
  def with(context)
24
41
  normalized = validate_context(context)
25
- state = ensure_state
26
- return yield if state[:stack].size >= MAX_DEPTH
27
-
28
- state[:stack].push(normalized)
42
+ parent = current_frame
43
+
44
+ new_depth = parent ? parent.depth + 1 : 1
45
+ effective = new_depth > MAX_DEPTH ? Context::UNKNOWN : normalized
46
+ frame = Frame.new(
47
+ context: effective,
48
+ parent: parent,
49
+ depth: new_depth,
50
+ pid: Process.pid,
51
+ thread_id: Thread.current.object_id
52
+ )
53
+
54
+ Fiber[FRAME_KEY] = frame
29
55
  begin
30
56
  yield
31
57
  ensure
32
- state[:stack].pop
58
+ # Only restore if our frame is still the active one.
59
+ # If clear! was called (or another with replaced it), skip restore.
60
+ Fiber[FRAME_KEY] = parent if Fiber[FRAME_KEY].equal?(frame)
33
61
  end
34
62
  end
35
63
 
64
+ def clear!
65
+ Fiber[FRAME_KEY] = nil
66
+ end
67
+
68
+ # Compatibility alias for clear!.
36
69
  def reset!
37
- fiber = Fiber.current
38
- fiber.remove_instance_variable(IVAR_KEY) if fiber.instance_variable_defined?(IVAR_KEY)
39
- fiber.remove_instance_variable(IVAR_PID_KEY) if fiber.instance_variable_defined?(IVAR_PID_KEY)
70
+ clear!
40
71
  end
41
72
 
73
+ # Clear context after fork.
42
74
  def after_fork!
43
- reset!
75
+ clear!
44
76
  end
45
77
 
46
78
  private
47
79
 
48
- def current_state
49
- fiber = Fiber.current
50
- return nil unless fiber.instance_variable_defined?(IVAR_KEY)
51
-
52
- pid = fiber.instance_variable_defined?(IVAR_PID_KEY) ? fiber.instance_variable_get(IVAR_PID_KEY) : nil
53
- return nil unless pid == Process.pid
54
-
55
- { stack: fiber.instance_variable_get(IVAR_KEY), pid: pid }
56
- end
57
-
58
- def ensure_state
59
- fiber = Fiber.current
60
- pid = Process.pid
61
-
62
- if fiber.instance_variable_defined?(IVAR_KEY)
63
- stored_pid = fiber.instance_variable_defined?(IVAR_PID_KEY) ? fiber.instance_variable_get(IVAR_PID_KEY) : nil
64
- return { stack: fiber.instance_variable_get(IVAR_KEY), pid: pid } if stored_pid == pid
65
-
66
- # PID mismatch - reset
67
- reset!
68
- end
80
+ def current_frame
81
+ frame = Fiber[FRAME_KEY]
82
+ return nil unless frame&.pid == Process.pid
83
+ return nil unless frame.thread_id == Thread.current.object_id
69
84
 
70
- stack = []
71
- fiber.instance_variable_set(IVAR_KEY, stack)
72
- fiber.instance_variable_set(IVAR_PID_KEY, pid)
73
- { stack: stack, pid: pid }
85
+ frame
74
86
  end
75
87
 
76
88
  def validate_context(value)
@@ -7,6 +7,7 @@ require_relative '../execution_context'
7
7
  require_relative '../rails_integration'
8
8
  require_relative '../recorder'
9
9
  require_relative '../redactor'
10
+ require_relative '../scheduler_snapshot'
10
11
 
11
12
  module FiberAudit
12
13
  module Runtime
@@ -27,7 +28,8 @@ module FiberAudit
27
28
  :thread_id,
28
29
  :fiber_id,
29
30
  :measurements,
30
- :execution_context
31
+ :execution_context,
32
+ :scheduler_snapshot
31
33
  )
32
34
 
33
35
  attr_reader :recorder, :clock, :redactor, :active_operations, :owner_pid, :execution_context_store
@@ -125,13 +127,15 @@ module FiberAudit
125
127
  thread = Thread.current
126
128
  fiber = Fiber.current
127
129
  captured_context = capture_execution_context
130
+ captured_scheduler_snapshot = capture_scheduler_snapshot
128
131
  handle = active_operations.register(
129
132
  operation: canonical_operation,
130
133
  monotonic_ns: started_ns,
131
134
  location: location,
132
135
  execution_context: captured_context,
133
136
  thread: thread,
134
- fiber: fiber
137
+ fiber: fiber,
138
+ scheduler_snapshot: captured_scheduler_snapshot
135
139
  )
136
140
  Observation.new(
137
141
  operation: canonical_operation,
@@ -141,7 +145,8 @@ module FiberAudit
141
145
  thread_id: thread.object_id,
142
146
  fiber_id: fiber.object_id,
143
147
  measurements: normalized_measurements,
144
- execution_context: captured_context
148
+ execution_context: captured_context,
149
+ scheduler_snapshot: captured_scheduler_snapshot
145
150
  )
146
151
  end
147
152
  # rubocop:enable Metrics/BlockLength
@@ -155,6 +160,10 @@ module FiberAudit
155
160
  Context::UNKNOWN
156
161
  end
157
162
 
163
+ def capture_scheduler_snapshot
164
+ SchedulerSnapshotCapture.capture
165
+ end
166
+
158
167
  def emit_start_observation(observation)
159
168
  emit_observation(:operation_started, observation, monotonic_ns: observation.started_monotonic_ns)
160
169
  rescue StandardError => e
@@ -207,11 +216,14 @@ module FiberAudit
207
216
  values.merge!(generated)
208
217
  end
209
218
  values[:operation_sequence] = observation.handle&.sequence
219
+ # Include scheduler snapshot measurements (immutable, captured at operation start)
220
+ values.merge!(observation.scheduler_snapshot.to_measurements) if observation.scheduler_snapshot
210
221
  values
211
222
  end
212
223
 
213
224
  def emit_observation(kind, observation, monotonic_ns:, duration_ns: nil, measurements: nil)
214
225
  values = measurements || observation.measurements.merge(operation_sequence: observation.handle&.sequence)
226
+ values = merge_scheduler_measurements_for_emit(values.dup, observation.scheduler_snapshot)
215
227
  recorder.record do
216
228
  Event.new(
217
229
  kind: kind,
@@ -229,6 +241,15 @@ module FiberAudit
229
241
  end
230
242
  end
231
243
 
244
+ def merge_scheduler_measurements_for_emit(values, scheduler_snapshot)
245
+ return values unless scheduler_snapshot
246
+
247
+ scheduler_snapshot.to_measurements.each do |key, value|
248
+ values[key.to_sym] = value unless values.key?(key.to_sym)
249
+ end
250
+ values
251
+ end
252
+
232
253
  def normalize_measurements(value)
233
254
  raise RuntimeContractError, 'probe measurements must be a Hash' unless value.is_a?(Hash)
234
255