Skip to content

Commit 2d72412

Browse files
committed
Address code-review findings on the policy mechanism
Eleven findings from the six-agent han review were confirmed by an adversarial validation pass (with live reproductions); all confirmed code findings are fixed here: - Validate policy names eagerly at every declaration site (Worker.new, Gang.new, Shifty.configure) via Policy.validate!, matching with_policy; a typo now fails where it was written, not at first shift inside a Fiber. - Remove @resolved_policy memoization so what effective_policy reports is always what intake enforces, even after late policy changes. - Rescue PolicyError in Worker#shift and discard the terminated Fiber, so a caller that rescues a PolicyViolation can keep shifting; the violating value is consumed and the pipeline continues with the next. - Govern Gang#shift's criteria-bypass path through the entry worker's intake, matching Worker#shift's bypass behavior. - Persist a Gang's declared pipeline policy and apply it to workers appended after the declaration. - Nil-guard Gang#supply so with_policy on an empty gang works. - Cycle-guard the with_policy supply-chain walk (self-referential pipes previously hung). - Deprecation-warn on ALL side_worker mode: values, with a message that names what the user actually typed; mode: :hardened maps to policy: :isolated without double-warning. - Delegate side_worker's scratch copy to Policy::Isolated (removes the duplicated Marshal round-trip). - Bound the PolicyViolation reachability walk (iterative, 50k-node cap) so a deeply nested value can't SystemStackError during error construction and mask the violation. - New specs: eager validation, violation recovery, gang bypass/append/ precedence/empty-gang, self-pipe termination, mode: :normal warning, Struct branch of the receiver heuristic.
1 parent 50eae84 commit 2d72412

7 files changed

Lines changed: 185 additions & 23 deletions

File tree

lib/shifty/configuration.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
module Shifty
22
class Configuration
3-
attr_accessor :default_policy
3+
attr_reader :default_policy
44

55
def initialize
66
@default_policy = :frozen
77
end
8+
9+
def default_policy=(policy_name)
10+
@default_policy = Policy.validate!(policy_name)
11+
end
812
end
913

1014
class << self

lib/shifty/dsl.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def relay_worker(options = {}, &block)
3030
def side_worker(options = {}, &block)
3131
options[:tags] ||= []
3232
options[:tags] << :side_effect
33-
options[:policy] = :hardened if options.delete(:mode) == :hardened
33+
deprecate_mode_option!(options)
3434
ensure_regular_arity!(block)
3535

3636
# The block must ask the worker for its policy at shift time, so the
@@ -42,7 +42,7 @@ def side_worker(options = {}, &block)
4242
# Under :isolated the block observes a private scratch copy, so
4343
# its mutations evaporate and the untouched value flows on.
4444
used_value = (worker.effective_policy == :isolated) ?
45-
Marshal.load(Marshal.dump(v)) : v
45+
Policy::Isolated.call(v, worker: worker) : v
4646

4747
block.call(used_value)
4848
end
@@ -143,6 +143,19 @@ def handoff(something)
143143

144144
private
145145

146+
def deprecate_mode_option!(options)
147+
return unless options.key?(:mode)
148+
mode = options.delete(:mode)
149+
if mode == :hardened
150+
warn "[shifty] side_worker mode: :hardened is deprecated and will be " \
151+
"removed in 1.0.0; use policy: :isolated instead."
152+
options[:policy] ||= :isolated
153+
else
154+
warn "[shifty] side_worker's mode: option is deprecated and ignored " \
155+
"(received mode: #{mode.inspect}); declare a policy: instead."
156+
end
157+
end
158+
146159
def throw_with(*msg)
147160
raise WorkerInitializationError.new([msg].flatten.join(" "))
148161
end

lib/shifty/errors.rb

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,24 +64,34 @@ def receiver_description
6464
end
6565
end
6666

67+
# Bounds the diagnostic graph walk; past this the heuristic gives up
68+
# and reports the honest "inspect both to judge" fallback rather than
69+
# risking a SystemStackError that would mask the violation itself.
70+
MAX_REACHABILITY_NODES = 50_000
71+
6772
def reachable_from_value?
68-
reachable?(value, receiver, {}.compare_by_identity)
73+
seen = {}.compare_by_identity
74+
stack = [value]
75+
until stack.empty?
76+
node = stack.pop
77+
next if node.nil? || seen[node]
78+
return false if seen.size >= MAX_REACHABILITY_NODES
79+
seen[node] = true
80+
return true if node.equal?(receiver)
81+
stack.concat(children_of(node))
82+
end
83+
false
6984
end
7085

71-
def reachable?(node, target, seen)
72-
return false if node.nil? || seen[node]
73-
seen[node] = true
74-
return true if node.equal?(target)
75-
76-
children = case node
86+
def children_of(node)
87+
case node
7788
when Array then node
7889
when Hash then node.keys + node.values
7990
when Struct then node.to_a
8091
else
8192
members = (node.respond_to?(:to_h) && node.is_a?(Data)) ? node.to_h.values : []
8293
members + node.instance_variables.map { |iv| node.instance_variable_get(iv) }
8394
end
84-
children.any? { |child| reachable?(child, target, seen) }
8595
end
8696
end
8797

lib/shifty/gang.rb

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,25 @@ def initialize(workers = [], p = {})
1212
@roster = Roster.new(workers)
1313
self.criteria = p[:criteria]
1414
self.tags = p[:tags]
15-
self.pipeline_policy = Policy.canonical(p[:policy]) if p[:policy]
15+
self.pipeline_policy = Policy.validate!(p[:policy]) if p[:policy]
1616
end
1717

1818
def pipeline_policy=(policy_name)
19+
# Persisted so workers appended after the declaration inherit it.
20+
@pipeline_policy = policy_name
1921
roster.workers.each { |w| w.pipeline_policy = policy_name }
2022
end
2123

24+
attr_reader :pipeline_policy
25+
2226
def shift
2327
if criteria_passes?
2428
roster.last.shift
2529
else
26-
roster.first.supply.shift
30+
# Even when the gang's criteria bypasses its workers, the value
31+
# still crosses the gang's boundary — govern it with the entry
32+
# worker's policy, matching Worker#shift's bypass behavior.
33+
roster.first.intake(roster.first.supply.shift)
2734
end
2835
end
2936

@@ -32,7 +39,7 @@ def ready_to_work?
3239
end
3340

3441
def supply
35-
roster.first.supply
42+
roster.first&.supply
3643
end
3744

3845
def supply=(supplier)
@@ -47,6 +54,7 @@ def supplies(subscribing_worker)
4754

4855
def append(worker)
4956
roster << worker
57+
worker.pipeline_policy = pipeline_policy if pipeline_policy
5058
end
5159

5260
class << self

lib/shifty/policy.rb

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ module Shifty
44
# policy declaration (its contract) always wins over the pipeline default.
55
module PolicyDeclarable
66
def with_policy(policy_name)
7-
policy_name = Policy.canonical(policy_name)
8-
Policy.resolve(policy_name)
7+
policy_name = Policy.validate!(policy_name)
98
node = self
10-
while node.respond_to?(:pipeline_policy=)
9+
seen = {}.compare_by_identity
10+
while node.respond_to?(:pipeline_policy=) && !seen[node]
11+
seen[node] = true
1112
node.pipeline_policy = policy_name
1213
node = node.supply
1314
end
@@ -72,6 +73,16 @@ def canonical(name)
7273
name
7374
end
7475
end
76+
77+
# Canonicalizes and validates a policy name at declaration time, so
78+
# a typo fails where it was written rather than at first shift.
79+
def validate!(name)
80+
canonical(name).tap do |canonical_name|
81+
unless TABLE.key?(canonical_name)
82+
raise ArgumentError, "unknown policy #{name.inspect}"
83+
end
84+
end
85+
end
7586
end
7687

7788
# Wraps a worker's supply so that values the task pulls directly

lib/shifty/worker.rb

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def initialize(p = {}, &block)
1919
@supply = p[:supply]
2020
@task = block || p[:task]
2121
@context = p[:context] || OpenStruct.new
22-
@policy = Policy.canonical(p[:policy]) if p[:policy]
22+
@policy = Policy.validate!(p[:policy]) if p[:policy]
2323
@name = p[:name]
2424
self.criteria = p[:criteria]
2525
self.tags = p[:tags]
@@ -33,12 +33,18 @@ def effective_policy
3333
# boundary. Public because Policy::Supply routes a task's own
3434
# supply.shift calls back through it.
3535
def intake(value)
36-
resolved_policy.call(value, worker: self)
36+
Policy.resolve(effective_policy).call(value, worker: self)
3737
end
3838

3939
def shift
4040
ensure_ready_to_work!
4141
workflow.resume
42+
rescue PolicyError
43+
# The raising Fiber is terminated and can never be resumed; discard
44+
# it so a caller that rescues the violation can keep shifting.
45+
# Closure and context state survive — only the loop restarts.
46+
@my_little_machine = nil
47+
raise
4248
end
4349

4450
def ready_to_work?
@@ -106,10 +112,6 @@ def policy_supply
106112
supply && Policy::Supply.new(supply, self)
107113
end
108114

109-
def resolved_policy
110-
@resolved_policy ||= Policy.resolve(effective_policy)
111-
end
112-
113115
def default_task
114116
proc { |value| value }
115117
end

spec/shifty/handoff_policy_spec.rb

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,107 @@ def o.x
312312
end
313313
end
314314

315+
describe "eager policy validation at declaration time" do
316+
context "Worker.new rejects an unknown policy immediately" do
317+
Then do
318+
expect { Worker.new(policy: :bogus) { |v| v } }
319+
.to raise_error(ArgumentError, /unknown policy :bogus/)
320+
end
321+
end
322+
323+
context "Gang.new rejects an unknown policy immediately" do
324+
Then do
325+
expect { Gang.new([Worker.new { |v| v }], policy: :bogus) }
326+
.to raise_error(ArgumentError, /unknown policy :bogus/)
327+
end
328+
end
329+
330+
context "the global default rejects an unknown policy immediately" do
331+
Then do
332+
expect { Shifty.configure { |c| c.default_policy = :bogus } }
333+
.to raise_error(ArgumentError, /unknown policy :bogus/)
334+
end
335+
end
336+
end
337+
338+
describe "recovery after a policy violation" do
339+
context "a rescued PolicyViolation does not kill the pipeline" do
340+
Given(:source) { source_worker [[:bad], [:good]] }
341+
Given(:mutator) do
342+
Worker.new { |v| (v == [:bad]) ? v << :x : v }
343+
end
344+
Given(:pipeline) { source | mutator }
345+
346+
When(:first_error) do
347+
pipeline.shift
348+
nil
349+
rescue PolicyViolation => e
350+
e
351+
end
352+
353+
Then { expect(first_error).to be_a(PolicyViolation) }
354+
And { expect(pipeline.shift).to eq([:good]) }
355+
end
356+
end
357+
358+
describe "Gang boundary behaviors" do
359+
Given(:mutable_source) { Worker.new { [:raw] } }
360+
361+
context "criteria-bypassed values are still governed at the gang boundary" do
362+
Given(:inner) { Worker.new { |v| v } }
363+
Given(:gang) { Gang.new([inner], criteria: ->(g) { false }) }
364+
Given { gang.supply = mutable_source }
365+
366+
When(:result) { gang.shift }
367+
368+
Then { expect(result).to eq([:raw]) }
369+
And { expect(result).to be_frozen }
370+
end
371+
372+
context "a worker appended after a policy declaration inherits it" do
373+
Given(:early) { Worker.new { |v| v } }
374+
Given(:late) { Worker.new { |v| v } }
375+
Given(:gang) { Gang[early] }
376+
When do
377+
gang.with_policy(:isolated)
378+
gang.append(late)
379+
end
380+
381+
Then { expect(late.effective_policy).to eq(:isolated) }
382+
end
383+
384+
context "a member's own declaration beats the gang's policy" do
385+
Given(:declared) { Worker.new(policy: :shared) { |v| v } }
386+
Given!(:gang) { Gang.new([declared], policy: :isolated) }
387+
388+
Then { expect(declared.effective_policy).to eq(:shared) }
389+
end
390+
391+
context "declaring policy on an empty gang works (populate later)" do
392+
Given(:gang) { Gang.new([]) }
393+
When(:result) { gang.with_policy(:isolated) }
394+
Then { expect(result).to be gang }
395+
end
396+
end
397+
398+
describe "with_policy on a self-referential pipe terminates" do
399+
Given(:worker) { Worker.new { |v| v } }
400+
When(:result) { (worker | worker).with_policy(:isolated) } # standard:disable Lint/BinaryOperatorWithIdenticalOperands
401+
Then { expect(result).to be worker }
402+
And { expect(worker.effective_policy).to eq(:isolated) }
403+
end
404+
405+
describe "deprecated mode: values other than :hardened warn and are ignored" do
406+
Given(:warning) do
407+
capture_stderr do
408+
@worker = side_worker(mode: :normal) { |v| v }
409+
end
410+
end
411+
412+
Then { expect(warning).to match(/mode: option is deprecated and ignored/) }
413+
And { expect(@worker.effective_policy).to eq(:frozen) }
414+
end
415+
315416
describe "PolicyViolation receiver heuristic" do
316417
Given(:catch_violation) do
317418
lambda do |pipeline|
@@ -332,6 +433,19 @@ def o.x
332433
And { expect(violation.message).to match(/reachable from the handed-off value/) }
333434
end
334435

436+
context "mutating an object nested inside a handed-off Struct" do
437+
Given(:struct_class) { Struct.new(:items) }
438+
Given(:source) do
439+
klass = struct_class
440+
Worker.new { klass.new([1]) }
441+
end
442+
Given(:mutator) { Worker.new { |v| v.items << 2 } }
443+
When(:violation) { catch_violation.call(source | mutator) }
444+
445+
Then { expect(violation.receiver).to eq([1]) }
446+
And { expect(violation.message).to match(/reachable from the handed-off value/) }
447+
end
448+
335449
context "an unrelated FrozenError from the task's own code is not misattributed" do
336450
Given(:own_frozen_thing) { [:mine].freeze }
337451
Given(:source) { Worker.new { [:foo] } }

0 commit comments

Comments
 (0)