Skip to content

Make the router's routing state authoritative instead of sniffed - #630

Draft
GoodForOneFare wants to merge 1 commit into
mainfrom
gordo-stdout-router-reenable
Draft

Make the router's routing state authoritative instead of sniffed#630
GoodForOneFare wants to merge 1 commit into
mainfrom
gordo-stdout-router-reenable

Conversation

@GoodForOneFare

@GoodForOneFare GoodForOneFare commented Aug 14, 2026

Copy link
Copy Markdown
Member

Bug

StdoutRouter.disable restored the original write method but left the write_without_cli_ui marker method installed on the stream. enabled? checked only for that marker, so after a disable the process reached a contradictory, unrecoverable state:

  • routing is actually inactive ($stdout.write is the original method),
  • enabled? still reports true,
  • enable sees "already enabled" and refuses to do anything, forever.

Worse, Capture#run's assert_enabled! still passed in this state (the marker is there), so captures ran but their hook never fired — captures silently returned empty output instead of raising.

with_enabled compounded this: it unconditionally disabled on exit, so entering that scope in a process that had already enabled the router — the normal production state, where the router is enabled once at startup — permanently broke routing for the rest of the process (verified on main).

Fix

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 — which previously crashed enable outright) can recover. enable returns whether it changed anything.
  • with_enabled unroutes exactly the streams it routed, so nesting inside an already-enabled router leaves that router alone.
  • Writer holds the stream's pre-routing write method, so it no longer dispatches through the write_without_cli_ui alias. 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; a pre-existing foreign singleton write is still restored intact.
  • 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. The alias itself is kept for compatibility while routed.
  • NotEnabled moves out of class << self, where it was only reachable as StdoutRouter.singleton_class::NotEnabled — i.e. nobody could actually rescue the error Capture#run raises.
  • ensure_activated is now just enable, which is idempotent.

Tests

  • Full enable → disable → re-enable cycle: enabled? is truthful at each step and a capture works after re-enabling.
  • disable leaves no singleton residue on the streams.
  • A pre-existing singleton write is restored after disable (and still sees writes while routed).
  • Half-routed recovery ($stderr reassigned after enable) and $stderr aliased to $stdout.
  • An in-flight write captured before disable still works after it.
  • Capture raises the (now rescuable) NotEnabled when the router is disabled.
  • Both write_without_routing paths (routed bypass, unrouted fallback).
  • with_enabled nesting in both directions.

All run against capture_io's swapped-in StringIO streams, so they can't interfere with a router installed on the real stdio streams.

🤖 Generated with Claude Code

(posted by an LLM bot on behalf of Gord)

@GoodForOneFare
GoodForOneFare force-pushed the gordo-stdout-router-reenable branch from bb136f6 to d9e06ba Compare August 14, 2026 19:33
@GoodForOneFare GoodForOneFare changed the title Make StdoutRouter.disable reversible and with_enabled state-preserving Make the router's routing state authoritative instead of sniffed Aug 14, 2026
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>
Assisted-By: devx/c0685ec8-2be2-4abc-9131-691e185f92ae
@GoodForOneFare
GoodForOneFare force-pushed the gordo-stdout-router-reenable branch from d9e06ba to 67db0b9 Compare August 14, 2026 20:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant