Module: Axn::Internal::CycleGuard
- Defined in:
- lib/axn/internal/cycle_guard.rb
Overview
Guards a recursive walk over caller-supplied containers against self-referential (cyclic)
structures. Every walker that descends into arbitrary Hash/Array values for observability —
log formatting, exception-report formatting, facet coercion, sensitive masking — would
otherwise recurse to SystemStackError on a = [1]; a << a, taking down the call from a side
channel (SystemStackError is not a StandardError, so it escapes the normal result path).
Mirrors what Ruby's own #inspect does for recursive structures: emit a placeholder for the
container already open on the current path. Identity-keyed (compare_by_identity), never
==/hash: a cycle is about the same OBJECT reappearing, and a large or custom-==
container must not be compared by value here.
Defined Under Namespace
Classes: Placeholder
Constant Summary collapse
- HASH_PLACEHOLDER =
Placeholder.new("{...}").freeze
- ARRAY_PLACEHOLDER =
Placeholder.new("[...]").freeze
Class Method Summary collapse
-
.converted_or_placeholder(placeholder = HASH_PLACEHOLDER) ⇒ Object
For a conversion that walks and rebuilds the structure ITSELF —
ActionController::Parameters# to_unsafe_h, which recursively converts every nested container — a cycle inside raises before any guard of ours can observe the repeated container, so the conversion can only be attempted and caught. -
.decycle(value, seen = nil) ⇒ Object
A structurally-equal copy of
valuewith every self-referential container replaced by its placeholder, so the result can be handed to code that has no cycle guard of its own — namely ActiveSupport::ParameterFilter, which axn cannot fix in place. -
.guard(container, seen, on_cycle:) ⇒ Object
Yields the visited-set to use for the next level down, having marked
containeras open. -
.guard_pair(container, key, seen, on_cycle:) ⇒ Object
For a walk that descends TWO structures in lockstep — a shape graph and the value it describes — where the position on the path is the PAIR rather than the container alone.
Class Method Details
.converted_or_placeholder(placeholder = HASH_PLACEHOLDER) ⇒ Object
For a conversion that walks and rebuilds the structure ITSELF — ActionController::Parameters# to_unsafe_h, which recursively converts every nested container — a cycle inside raises before any
guard of ours can observe the repeated container, so the conversion can only be attempted and
caught. Yields the converted value, or the placeholder if it could not complete.
(A cycle can get inside Parameters only by in-place mutation of an already-nested Array: every
constructing path — new, []= — converts eagerly and so blows the stack at assignment.)
34 35 36 37 38 |
# File 'lib/axn/internal/cycle_guard.rb', line 34 def self.converted_or_placeholder(placeholder = HASH_PLACEHOLDER) yield rescue SystemStackError placeholder end |
.decycle(value, seen = nil) ⇒ Object
A structurally-equal copy of value with every self-referential container replaced by its
placeholder, so the result can be handed to code that has no cycle guard of its own — namely
ActiveSupport::ParameterFilter, which axn cannot fix in place. For that fallback only: a walker
axn owns should guard its own recursion with .guard instead, which needs no copy.
44 45 46 47 48 49 50 51 52 53 |
# File 'lib/axn/internal/cycle_guard.rb', line 44 def self.decycle(value, seen = nil) case value when Hash guard(value, seen, on_cycle: HASH_PLACEHOLDER) { |nested| value.transform_values { |element| decycle(element, nested) } } when Array guard(value, seen, on_cycle: ARRAY_PLACEHOLDER) { |nested| value.map { |element| decycle(element, nested) } } else value end end |
.guard(container, seen, on_cycle:) ⇒ Object
Yields the visited-set to use for the next level down, having marked container as open.
Returns on_cycle instead — without yielding — when container is already open on the
current path.
seen is nil at the top of a walk and allocated on first descent, so an acyclic scalar
costs nothing. Membership is popped on the way out (ensure), so a container repeated among
SIBLINGS still renders in full — again matching Ruby, where x = [1]; [x, x].inspect is
"[[1], [1]]", not "[[1], [...]]". Only genuine ancestry is a cycle.
96 97 98 99 100 101 102 103 104 105 106 |
# File 'lib/axn/internal/cycle_guard.rb', line 96 def self.guard(container, seen, on_cycle:) seen ||= {}.compare_by_identity return on_cycle if seen.key?(container) seen[container] = true begin yield seen ensure seen.delete(container) end end |
.guard_pair(container, key, seen, on_cycle:) ⇒ Object
For a walk that descends TWO structures in lockstep — a shape graph and the value it describes —
where the position on the path is the PAIR rather than the container alone. Guards on
(container, key): the same value revisited under the same shape node.
Keying on either half alone is wrong, in opposite directions, and both were measured. On the VALUE
alone, a self-referential value under an ordinary DECLARED shape is revisited one level down under a
different shape node that still has members to validate, so skipping it there drops real verdicts (a
required member of the second node stops being checked at all). On the SHAPE alone, a legitimately
repeated shape stops descending a value that still has members — which is why the mask in
redaction.rb guards the value.
Both halves are identity-keyed for the same reason guard is: a cycle is the same OBJECT
reappearing, and neither a caller's value nor its shape may be asked to hash or compare itself.
What this bounds is a CYCLIC graph. A GENERATIVE one — minting a fresh nested shape on every read —
repeats no pair and is endless rather than cyclic, so a walk that can meet one needs
ShapeGraph::MAX_NESTING as well. Every walk of a graph a class merely HOLDS needs both bounds.
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/axn/internal/cycle_guard.rb', line 72 def self.guard_pair(container, key, seen, on_cycle:) seen ||= {}.compare_by_identity nested = (seen[container] ||= {}.compare_by_identity) return on_cycle if nested.key?(key) nested[key] = true begin yield seen ensure # Popped on the way out on BOTH levels, so a pair repeated among SIBLINGS still walks in full and # the outer key does not accumulate an empty bag per value the walk has finished with. nested.delete(key) seen.delete(container) if nested.empty? end end |