Skip to content

Commit 311ef8c

Browse files
committed
Add logger: option.
1 parent b6a3c40 commit 311ef8c

File tree

5 files changed

+158
-10
lines changed

5 files changed

+158
-10
lines changed

gems.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@
2121
gem "sus"
2222
gem "covered"
2323
gem "decode"
24-
24+
2525
gem "rubocop"
2626
gem "rubocop-md"
2727
gem "rubocop-socketry"
2828

2929
gem "sus-fixtures-async"
30+
gem "sus-fixtures-console"
3031

3132
gem "bake-test"
3233
gem "bake-test-external"

lib/async/safe.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,14 @@ class << self
6161
#
6262
# This activates a TracePoint that tracks object access across fibers and threads.
6363
# There is no performance overhead when monitoring is disabled.
64-
def enable!
65-
@monitor ||= Monitor.new
64+
#
65+
# @parameter logger [Object | Nil] Optional logger to use for violations instead of raising exceptions.
66+
def enable!(logger: nil)
67+
if @monitor
68+
raise "Async::Safe is already enabled!"
69+
end
70+
71+
@monitor = Monitor.new(logger: logger)
6672
@monitor.enable!
6773
end
6874

lib/async/safe/monitor.rb

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,13 @@ class Monitor
6464
ASYNC_SAFE = true
6565

6666
# Initialize a new monitor instance.
67-
def initialize
67+
#
68+
# @parameter logger [Object | Nil] Optional logger to use for violations instead of raising exceptions.
69+
def initialize(logger: nil)
6870
@owners = ObjectSpace::WeakMap.new
6971
@mutex = Thread::Mutex.new
7072
@trace_point = nil
73+
@logger = logger
7174
end
7275

7376
attr :owners
@@ -128,12 +131,11 @@ def check_access(trace_point)
128131
if owner = @owners[object]
129132
# Violation if accessed from different fiber:
130133
if owner != current
131-
raise ViolationError.new(
132-
target: object,
133-
method: method,
134-
owner: owner,
135-
current: current,
136-
)
134+
if @logger
135+
@logger.warn(self, "Async::Safe violation detected!", klass: klass, method: method, owner: owner, current: current, backtrace: caller_locations(3..))
136+
else
137+
raise ViolationError.new(target: object, method: method, owner: owner, current: current)
138+
end
137139
end
138140
else
139141
# First access - record owner:

releases.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- `Thread::Queue` transfers ownership of objects popped from it.
6+
- Add support for `logger:` option in `Async::Safe.enable!` which logs violations instead of raising errors.
67

78
## v0.1.0
89

test/async/safe/logging.rb

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require "async/safe"
7+
require "sus/fixtures/console/captured_logger"
8+
require "console"
9+
10+
describe "Async::Safe Logging" do
11+
include_context Sus::Fixtures::Console::CapturedLogger
12+
13+
let(:test_class) do
14+
Class.new do
15+
def process
16+
"processed"
17+
end
18+
end
19+
end
20+
21+
after do
22+
Async::Safe.disable! if Async::Safe.monitor
23+
end
24+
25+
with "no logger (default)" do
26+
it "raises exceptions by default" do
27+
Async::Safe.enable!
28+
29+
test_object = test_class.new
30+
test_object.process # Establish ownership in main fiber
31+
32+
expect do
33+
Fiber.new do
34+
test_object.process # Should raise
35+
end.resume
36+
end.to raise_exception(Async::Safe::ViolationError)
37+
end
38+
39+
it "raises exceptions when no logger provided" do
40+
Async::Safe.enable!(logger: nil)
41+
42+
test_object = test_class.new
43+
test_object.process # Establish ownership in main fiber
44+
45+
expect do
46+
Fiber.new do
47+
test_object.process # Should raise
48+
end.resume
49+
end.to raise_exception(Async::Safe::ViolationError)
50+
end
51+
end
52+
53+
with "logger: Console" do
54+
it "logs violations instead of raising when logger provided" do
55+
Async::Safe.enable!(logger: Console)
56+
57+
test_object = test_class.new
58+
test_object.process # Establish ownership in main fiber
59+
60+
# This should not raise an exception
61+
expect do
62+
Fiber.new do
63+
test_object.process # Should log instead of raise
64+
end.resume
65+
end.not.to raise_exception
66+
67+
# Check that a warning was logged with structured data
68+
last_log = console_capture.last
69+
expect(last_log).to have_keys(
70+
severity: be == :warn,
71+
subject: be_a(Async::Safe::Monitor), # The subject is the monitor instance
72+
message: be == "Async::Safe violation detected!", # The actual message
73+
klass: be_a(Class),
74+
method: be == :process,
75+
owner: be_a(Fiber),
76+
current: be_a(Fiber),
77+
backtrace: be_a(Array)
78+
)
79+
80+
# Verify the fibers are different
81+
expect(last_log[:owner]).not.to be == last_log[:current]
82+
end
83+
84+
it "continues execution after logging violations" do
85+
Async::Safe.enable!(logger: Console)
86+
87+
test_object = test_class.new
88+
test_object.process # Establish ownership in main fiber
89+
90+
execution_completed = false
91+
92+
Fiber.new do
93+
test_object.process # Should log violation but not raise
94+
execution_completed = true # This should execute
95+
end.resume
96+
97+
expect(execution_completed).to be == true
98+
99+
# Verify that a warning was logged with all expected structured data
100+
expect(console_capture.last).to have_keys(
101+
severity: be == :warn,
102+
subject: be_a(Async::Safe::Monitor), # The subject is the monitor instance
103+
message: be == "Async::Safe violation detected!", # The actual message
104+
klass: be_a(Class),
105+
method: be == :process,
106+
owner: be_a(Fiber),
107+
current: be_a(Fiber),
108+
backtrace: be_a(Array)
109+
)
110+
end
111+
112+
it "includes useful backtrace information in logs" do
113+
Async::Safe.enable!(logger: Console)
114+
115+
test_object = test_class.new
116+
test_object.process # Establish ownership in main fiber
117+
118+
Fiber.new do
119+
test_object.process # Should log violation with backtrace
120+
end.resume
121+
122+
last_log = console_capture.last
123+
backtrace = last_log[:backtrace]
124+
125+
# Backtrace should be non-empty
126+
expect(backtrace.length).to be > 0
127+
128+
# The backtrace entries should be Thread::Backtrace::Location objects
129+
first_entry = backtrace.first
130+
expect(first_entry).to be_a(Thread::Backtrace::Location)
131+
132+
# The backtrace should contain entries - check if any reference the test files
133+
backtrace_strings = backtrace.map(&:to_s)
134+
has_test_reference = backtrace_strings.any? { |s| s.match?(/test.*logging\.rb/) }
135+
expect(has_test_reference).to be == true
136+
end
137+
end
138+
end

0 commit comments

Comments
 (0)