Skip to content

Commit 7cd4cd5

Browse files
GoodForOneFareshopify-riverclaude
committed
Restore shadowed thread-locals; stop mutating report_on_exception
Three scope helpers in StdoutRouter hard-reset thread state on exit instead of restoring the value they replaced: * Capture#run set Thread.current.report_on_exception = false and never restored it, permanently disabling exception reporting on any thread that ran a capture. * Capture#run reset :cliui_current_capture to nil, so a nested capture left the still-active outer capture invisible to current_capture consumers such as in_alternate_screen. * with_id reset :cliui_output_id to nil, so a nested ID scope stripped the outer scope's output labelling for the rest of the outer block. Thread-locals are now set and restored through one helper: StdoutRouter.with_thread_locals sets values for the duration of a block and restores whatever they shadowed. That also removes the clobbering window the hand-rolled save/restore pairs had: a method-level ensure runs even when something raises before the save, nulling the enclosing scope's values. Capture#run no longer touches report_on_exception at all. The 2019 suppression (fec0004) existed to silence Ruby's thread-death report when a spinner task raised; SpinGroup now runs tasks through WorkQueue, which rescues errors in-thread, so no report fires there either way, and the respond_to? guard is dead at required_ruby_version >= 3.2. Restoring the flag rather than dropping it produced the same visible change (the report reappears for callers that run a Capture in a thread of their own and let it die), so drop it: cli-ui stops writing a thread-wide diagnostic flag it doesn't own. A subprocess test pins the now-deliberate thread-death report. Co-authored-by: River <river@shopify.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 83cbecb commit 7cd4cd5

2 files changed

Lines changed: 164 additions & 36 deletions

File tree

lib/cli/ui/stdout_router.rb

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -209,36 +209,27 @@ def run
209209

210210
StdoutRouter.assert_enabled!
211211

212-
Thread.current[:cliui_current_capture] = self
213-
214-
prev_frame_inset = Thread.current[:no_cliui_frame_inset]
215-
prev_hook = Thread.current[:cliui_output_hook]
216-
217-
if Thread.current.respond_to?(:report_on_exception)
218-
Thread.current.report_on_exception = false
219-
end
220-
221-
self.class.with_stdin_masked do
222-
Thread.current[:no_cliui_frame_inset] = !@with_frame_inset
223-
Thread.current[:cliui_output_hook] = ->(data, stream) do
224-
stream = :stdout if @merged_output
225-
case stream
226-
when :stdout
227-
@out.write(data)
228-
@duplicate_output_to.write(data)
229-
when :stderr
230-
@err.write(data)
231-
else raise
212+
StdoutRouter.with_thread_locals({ cliui_current_capture: self }) do
213+
self.class.with_stdin_masked do
214+
StdoutRouter.with_thread_locals({
215+
no_cliui_frame_inset: !@with_frame_inset,
216+
cliui_output_hook: ->(data, stream) do
217+
stream = :stdout if @merged_output
218+
case stream
219+
when :stdout
220+
@out.write(data)
221+
@duplicate_output_to.write(data)
222+
when :stderr
223+
@err.write(data)
224+
else raise
225+
end
226+
print_captured_output # suppress writing to terminal by default
227+
end,
228+
}) do
229+
@block.call
232230
end
233-
print_captured_output # suppress writing to terminal by default
234231
end
235-
236-
@block.call
237232
end
238-
ensure
239-
Thread.current[:cliui_output_hook] = prev_hook
240-
Thread.current[:no_cliui_frame_inset] = prev_frame_inset
241-
Thread.current[:cliui_current_capture] = nil
242233
end
243234

244235
#: -> String
@@ -314,19 +305,26 @@ class << self
314305
#: io_like?
315306
attr_accessor :duplicate_output_to
316307

308+
# Sets thread-local values for the duration of the block, restoring whatever they shadowed.
309+
#: [T] (Hash[Symbol, untyped] values) { -> T } -> T
310+
def with_thread_locals(values, &block)
311+
previous = values.keys.to_h { |key| [key, Thread.current[key]] }
312+
values.each { |key, value| Thread.current[key] = value }
313+
yield
314+
ensure
315+
previous&.each { |key, value| Thread.current[key] = value }
316+
end
317+
317318
#: [T] (on_streams: Array[io_like]) { (String id) -> T } -> T
318319
def with_id(on_streams:, &block)
319320
require 'securerandom'
320321
id = format('%05d', rand(10**5))
321-
Thread.current[:cliui_output_id] = {
322-
id: id,
323-
streams: on_streams.map do |stream|
324-
stream #: as io_like
325-
end,
326-
}
327-
yield(id)
328-
ensure
329-
Thread.current[:cliui_output_id] = nil
322+
streams = on_streams.map do |stream|
323+
stream #: as io_like
324+
end
325+
with_thread_locals({ cliui_output_id: { id: id, streams: streams } }) do
326+
yield(id)
327+
end
330328
end
331329

332330
#: -> Hash[Symbol, (String | io_like)]?

test/cli/ui/stdout_router_test.rb

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,136 @@ def test_current_id
2424
end
2525
end
2626

27+
def test_nested_with_id_restores_outer_id
28+
StdoutRouter.with_id(on_streams: [$stdout]) do |outer_id|
29+
StdoutRouter.with_id(on_streams: [$stdout]) do |inner_id|
30+
assert_equal(inner_id, StdoutRouter.current_id&.fetch(:id))
31+
end
32+
assert_equal(outer_id, StdoutRouter.current_id&.fetch(:id))
33+
end
34+
assert_nil(StdoutRouter.current_id)
35+
end
36+
37+
def test_nested_with_id_restores_outer_id_when_inner_raises
38+
StdoutRouter.with_id(on_streams: [$stdout]) do |outer_id|
39+
assert_raises(RuntimeError) do
40+
StdoutRouter.with_id(on_streams: [$stdout]) { raise('inner') }
41+
end
42+
assert_equal(outer_id, StdoutRouter.current_id&.fetch(:id))
43+
end
44+
assert_nil(StdoutRouter.current_id)
45+
end
46+
47+
def test_with_thread_locals_restores_previous_values
48+
Thread.current[:cliui_test_local] = :outer
49+
StdoutRouter.with_thread_locals({ cliui_test_local: :inner }) do
50+
assert_equal(:inner, Thread.current[:cliui_test_local])
51+
end
52+
assert_equal(:outer, Thread.current[:cliui_test_local])
53+
54+
assert_raises(RuntimeError) do
55+
StdoutRouter.with_thread_locals({ cliui_test_local: :inner }) { raise('boom') }
56+
end
57+
assert_equal(:outer, Thread.current[:cliui_test_local])
58+
ensure
59+
Thread.current[:cliui_test_local] = nil
60+
end
61+
62+
def test_capture_leaves_report_on_exception_untouched
63+
capture_io do
64+
StdoutRouter.with_enabled do
65+
prev = Thread.current.report_on_exception
66+
begin
67+
[true, false].each do |value|
68+
Thread.current.report_on_exception = value
69+
during = nil
70+
StdoutRouter::Capture.new { during = Thread.current.report_on_exception }.run
71+
assert_equal(value, during)
72+
assert_equal(value, Thread.current.report_on_exception)
73+
end
74+
ensure
75+
Thread.current.report_on_exception = prev
76+
end
77+
end
78+
end
79+
end
80+
81+
def test_capture_failure_in_a_thread_still_reports_thread_death
82+
script = <<~RUBY
83+
require 'cli/ui'
84+
CLI::UI::StdoutRouter.enable
85+
thread = Thread.new { CLI::UI::StdoutRouter::Capture.new { raise('boom') }.run }
86+
begin
87+
thread.join
88+
rescue RuntimeError
89+
nil
90+
end
91+
RUBY
92+
93+
lib = File.expand_path('../../../lib', __dir__)
94+
stdout, stderr, _ = Open3.capture3(RbConfig.ruby, '-I', lib, '-e', script)
95+
96+
assert_match(/terminated with exception/, stderr, "stdout:\n#{stdout}\nstderr:\n#{stderr}")
97+
end
98+
99+
def test_nested_capture_restores_outer_capture
100+
capture_io do
101+
StdoutRouter.with_enabled do
102+
inner_current = nil
103+
restored_current = nil
104+
inner = StdoutRouter::Capture.new do
105+
inner_current = StdoutRouter::Capture.current_capture
106+
end
107+
outer = StdoutRouter::Capture.new do
108+
inner.run
109+
restored_current = StdoutRouter::Capture.current_capture
110+
end
111+
outer.run
112+
assert_same(inner, inner_current)
113+
assert_same(outer, restored_current)
114+
assert_nil(StdoutRouter::Capture.current_capture)
115+
end
116+
end
117+
end
118+
119+
def test_nested_capture_restores_outer_capture_and_hook_when_inner_raises
120+
capture_io do
121+
StdoutRouter.with_enabled do
122+
restored_current = nil
123+
inner = StdoutRouter::Capture.new { raise('inner') }
124+
outer = StdoutRouter::Capture.new do
125+
assert_raises(RuntimeError) { inner.run }
126+
restored_current = StdoutRouter::Capture.current_capture
127+
puts('after inner')
128+
end
129+
outer.run
130+
assert_same(outer, restored_current)
131+
assert_includes(outer.stdout, 'after inner')
132+
assert_nil(StdoutRouter::Capture.current_capture)
133+
end
134+
end
135+
end
136+
137+
def test_capture_restores_frame_inset_when_nested
138+
capture_io do
139+
StdoutRouter.with_enabled do
140+
inset_during_inner = nil
141+
inset_after_inner = nil
142+
inner = StdoutRouter::Capture.new(with_frame_inset: true) do
143+
inset_during_inner = Thread.current[:no_cliui_frame_inset]
144+
end
145+
outer = StdoutRouter::Capture.new(with_frame_inset: false) do
146+
inner.run
147+
inset_after_inner = Thread.current[:no_cliui_frame_inset]
148+
end
149+
outer.run
150+
assert_equal(false, inset_during_inner)
151+
assert_equal(true, inset_after_inner)
152+
assert_nil(Thread.current[:no_cliui_frame_inset])
153+
end
154+
end
155+
end
156+
27157
def test_frame_can_autoload_after_router_is_enabled
28158
script = <<~RUBY
29159
require 'stringio'

0 commit comments

Comments
 (0)