Skip to content

Commit d9e06ba

Browse files
GoodForOneFareshopify-riverclaude
committed
Make the router's routing state authoritative instead of sniffed
Disabling the router restored the original write method but left the write_without_cli_ui marker method installed on the stream. Since enabled? checked for that marker, the router reported itself enabled while routing was actually inactive, and a later enable refused to do anything. The process was stuck: output permanently bypassed routing, and Capture#run's assert_enabled! still passed while captures silently collected nothing. with_enabled compounded this by always disabling on exit, so entering that scope in a process that had already enabled the router (the common production state) permanently broke routing. Routing state now lives in a private stream => original-write registry rather than being inferred from a marker method on the stream, so nothing on the stream can make enabled? lie: * enable/disable act per stream instead of all-or-nothing, so a process that ends up half-routed (e.g. $stdout reassigned after enable, or $stderr aliased to $stdout) can recover. with_enabled unroutes exactly the streams it routed, leaving an already-enabled router alone. * Writer holds the stream's pre-routing write method, so it no longer dispatches through write_without_cli_ui. A write already in flight on another thread survives a concurrent disable. * deactivate restores only a write it displaced. Previously every disable left a singleton copy of the class's write behind, shadowing the class method for the life of the stream. * write_without_routing is the supported way to bypass the router. Callers currently reach for the write_without_cli_ui alias directly (dev's cd/edit commands do, unguarded); that alias only exists while the stream is routed, and now really does disappear on disable. * NotEnabled moves out of class << self, where it was only reachable as StdoutRouter.singleton_class::NotEnabled - i.e. nobody could rescue the error Capture#run raises. * ensure_activated is now just enable, which is idempotent. Co-authored-by: River <river@shopify.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 83cbecb commit d9e06ba

2 files changed

Lines changed: 204 additions & 21 deletions

File tree

lib/cli/ui/stdout_router.rb

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,19 @@
1111
module CLI
1212
module UI
1313
module StdoutRouter
14+
# Defined here rather than on the singleton class, where callers had no
15+
# way to name it.
16+
NotEnabled = Class.new(StandardError)
17+
1418
class Writer
15-
#: (io_like stream, Symbol name) -> void
16-
def initialize(stream, name)
19+
# `original_write` must be the stream's pre-routing `write`; `activate`
20+
# passes it in. Holding the method itself keeps an in-flight write
21+
# working after `deactivate` removes the WRITE_WITHOUT_CLI_UI alias.
22+
#: (io_like stream, Symbol name, ?Method original_write) -> void
23+
def initialize(stream, name, original_write = stream.method(:write))
1724
@stream = stream
1825
@name = name
26+
@original_write = original_write
1927
end
2028

2129
#: (*Object args) -> Integer
@@ -38,7 +46,7 @@ def write(*args)
3846
end
3947

4048
stream_args = prepend_id(@stream, strs) #: as untyped
41-
ret = @stream.write_without_cli_ui(*stream_args) #: as Integer
49+
ret = @original_write.call(*stream_args) #: as Integer
4250
if (dup = StdoutRouter.duplicate_output_to)
4351
begin
4452
dup_args = prepend_id(dup, strs) #: as untyped
@@ -309,8 +317,6 @@ def synchronize(&block)
309317
class << self
310318
WRITE_WITHOUT_CLI_UI = :write_without_cli_ui
311319

312-
NotEnabled = Class.new(StandardError)
313-
314320
#: io_like?
315321
attr_accessor :duplicate_output_to
316322

@@ -339,62 +345,100 @@ def assert_enabled!
339345
raise NotEnabled unless enabled?
340346
end
341347

348+
# Only unroutes what it routed, so nesting inside an already-enabled
349+
# router leaves that router alone.
342350
#: [T] { -> T } -> T
343351
def with_enabled(&block)
344-
enable
352+
activated = activate_current_streams
345353
yield
346354
ensure
347-
disable
355+
activated&.each { |stream| deactivate(stream) if enabled?(stream) }
348356
end
349357

350358
# TODO: remove this
351359
#: -> void
352360
def ensure_activated
353-
enable unless enabled?
361+
enable
354362
end
355363

364+
# Routes whichever of $stdout/$stderr isn't routed yet; returns whether
365+
# anything changed. Per-stream so a half-routed process can recover.
356366
#: -> bool
357367
def enable
358-
return false if enabled?($stdout) || enabled?($stderr)
359-
360-
activate($stdout, :stdout)
361-
activate($stderr, :stderr)
362-
true
368+
!activate_current_streams.empty?
363369
end
364370

365371
#: (?io_like stream) -> bool
366372
def enabled?(stream = $stdout)
367-
stream.respond_to?(WRITE_WITHOUT_CLI_UI)
373+
routed_streams.key?(stream)
368374
end
369375

370376
#: -> bool
371377
def disable
372-
return false unless enabled?($stdout) && enabled?($stderr)
378+
routed = [$stdout, $stderr].uniq.select { |stream| enabled?(stream) }
379+
routed.each { |stream| deactivate(stream) }
380+
!routed.empty?
381+
end
382+
383+
# Writes past the router: no frame inset, capture hooks, or output
384+
# duplication. Prefer this to calling WRITE_WITHOUT_CLI_UI directly,
385+
# which only exists while the stream is routed.
386+
#: (io_like stream, *Object args) -> Integer
387+
def write_without_routing(stream, *args)
388+
original_write = routed_streams[stream]
389+
write_args = args #: as untyped
390+
return stream.write(*write_args) unless original_write
373391

374-
deactivate($stdout)
375-
deactivate($stderr)
376-
true
392+
original_write.call(*write_args) #: as Integer
377393
end
378394

379395
private
380396

397+
# Routed stream => the `write` it had before routing. State lives here
398+
# rather than in a method on the stream, so nothing on the stream can
399+
# make `enabled?` lie. `deactivate` drops the entry.
400+
#: -> Hash[io_like, Method]
401+
def routed_streams
402+
@routed_streams ||= {}.compare_by_identity
403+
end
404+
405+
#: -> Array[io_like]
406+
def activate_current_streams
407+
activated = [] #: Array[io_like]
408+
{ stdout: $stdout, stderr: $stderr }.each do |streamname, stream|
409+
next if enabled?(stream)
410+
411+
activate(stream, streamname)
412+
activated << stream
413+
end
414+
activated
415+
end
416+
381417
#: (io_like stream) -> void
382418
def deactivate(stream)
419+
original_write = routed_streams.delete(stream)
383420
sc = stream.singleton_class
384421
sc.send(:remove_method, :write)
385-
sc.send(:alias_method, :write, WRITE_WITHOUT_CLI_UI)
422+
# Restore only a `write` we displaced; otherwise leave the class
423+
# method exposed again. The alias binds the method, not the name, so
424+
# it survives removing WRITE_WITHOUT_CLI_UI.
425+
sc.send(:alias_method, :write, WRITE_WITHOUT_CLI_UI) if original_write&.owner == sc
426+
sc.send(:remove_method, WRITE_WITHOUT_CLI_UI)
386427
end
387428

388429
#: (io_like stream, Symbol streamname) -> void
389430
def activate(stream, streamname)
390-
writer = StdoutRouter::Writer.new(stream, streamname)
431+
raise("#{streamname} is already routed") if stream.respond_to?(WRITE_WITHOUT_CLI_UI)
391432

392-
raise if stream.respond_to?(WRITE_WITHOUT_CLI_UI)
433+
original_write = stream.method(:write)
434+
writer = StdoutRouter::Writer.new(stream, streamname, original_write)
393435

436+
# Kept for callers that bypass the router through the alias itself.
394437
stream.singleton_class.send(:alias_method, WRITE_WITHOUT_CLI_UI, :write)
395438
stream.define_singleton_method(:write) do |*args|
396439
writer.write(*args)
397440
end
441+
routed_streams[stream] = original_write
398442
end
399443
end
400444
end

test/cli/ui/stdout_router_test.rb

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

27+
def test_router_can_reenable_after_disable
28+
# capture_io swaps in fresh StringIO streams, so enable/disable here
29+
# can't interfere with a router installed on the real stdio streams.
30+
capture_io do
31+
assert(StdoutRouter.enable)
32+
assert($stdout.respond_to?(:write_without_cli_ui))
33+
assert(StdoutRouter.disable)
34+
35+
refute_predicate(StdoutRouter, :enabled?)
36+
refute($stdout.respond_to?(:write_without_cli_ui))
37+
38+
assert(StdoutRouter.enable)
39+
assert_predicate(StdoutRouter, :enabled?)
40+
41+
cap = StdoutRouter::Capture.new { print('hi') }
42+
cap.run
43+
assert_equal('hi', cap.stdout)
44+
ensure
45+
StdoutRouter.disable
46+
end
47+
end
48+
49+
def test_capture_raises_when_the_router_is_disabled
50+
capture_io do
51+
assert_raises(StdoutRouter::NotEnabled) do
52+
StdoutRouter::Capture.new {}.run
53+
end
54+
end
55+
end
56+
57+
def test_disable_leaves_the_streams_as_it_found_them
58+
capture_io do
59+
StdoutRouter.enable
60+
StdoutRouter.disable
61+
62+
assert_empty($stdout.singleton_methods)
63+
assert_empty($stderr.singleton_methods)
64+
end
65+
end
66+
67+
def test_disable_restores_a_pre_existing_singleton_write
68+
capture_io do
69+
written = []
70+
$stdout.define_singleton_method(:write) do |*args|
71+
written << args.join
72+
super(*args)
73+
end
74+
75+
StdoutRouter.enable
76+
$stdout.write('routed')
77+
StdoutRouter.disable
78+
$stdout.write('plain')
79+
80+
assert_equal(['routed', 'plain'], written)
81+
assert_equal([:write], $stdout.singleton_methods)
82+
end
83+
end
84+
85+
def test_enable_recovers_a_half_routed_process
86+
capture_io do
87+
StdoutRouter.enable
88+
$stderr = StringIO.new
89+
90+
assert(StdoutRouter.enable)
91+
assert(StdoutRouter.enabled?($stderr))
92+
assert(StdoutRouter.disable)
93+
refute_predicate(StdoutRouter, :enabled?)
94+
ensure
95+
StdoutRouter.disable
96+
end
97+
end
98+
99+
def test_enable_handles_stderr_aliased_to_stdout
100+
capture_io do
101+
$stderr = $stdout
102+
103+
assert(StdoutRouter.enable)
104+
assert(StdoutRouter.disable)
105+
assert_empty($stdout.singleton_methods)
106+
end
107+
end
108+
109+
def test_in_flight_writes_survive_disable
110+
capture_io do
111+
StdoutRouter.enable
112+
in_flight = $stdout.method(:write)
113+
StdoutRouter.disable
114+
115+
in_flight.call('late')
116+
assert_includes($stdout.string, 'late')
117+
end
118+
end
119+
120+
def test_write_without_routing_bypasses_the_router
121+
capture_io do
122+
StdoutRouter.enable
123+
cap = StdoutRouter::Capture.new { StdoutRouter.write_without_routing($stdout, 'bypass') }
124+
cap.run
125+
126+
assert_equal('', cap.stdout)
127+
assert_includes($stdout.string, 'bypass')
128+
ensure
129+
StdoutRouter.disable
130+
end
131+
end
132+
133+
def test_write_without_routing_falls_back_to_write_when_disabled
134+
capture_io do
135+
StdoutRouter.write_without_routing($stdout, 'unrouted')
136+
137+
assert_includes($stdout.string, 'unrouted')
138+
end
139+
end
140+
141+
def test_with_enabled_preserves_already_enabled_router
142+
capture_io do
143+
StdoutRouter.enable
144+
StdoutRouter.with_enabled {}
145+
146+
assert_predicate(StdoutRouter, :enabled?)
147+
cap = StdoutRouter::Capture.new { print('still routed') }
148+
cap.run
149+
assert_equal('still routed', cap.stdout)
150+
ensure
151+
StdoutRouter.disable
152+
end
153+
end
154+
155+
def test_with_enabled_unroutes_only_what_it_routed
156+
capture_io do
157+
StdoutRouter.with_enabled do
158+
assert_predicate(StdoutRouter, :enabled?)
159+
end
160+
161+
refute_predicate(StdoutRouter, :enabled?)
162+
assert_empty($stdout.singleton_methods)
163+
end
164+
end
165+
27166
def test_frame_can_autoload_after_router_is_enabled
28167
script = <<~RUBY
29168
require 'stringio'

0 commit comments

Comments
 (0)