Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/errgonomic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ class ArgumentError < Error; end

class ResultRequiredError < Error; end

# Raised when a wrapped ActiveRecord attribute reader re-enters itself on
# the same record, catching runaway recursion at the first repeated frame
# instead of a SystemStackError thousands of frames later.
class RecursiveOptionalReadError < Error; end

class NotComparableError < StandardError; end

class SerializeError < TypeError; end
Expand Down
2 changes: 1 addition & 1 deletion lib/errgonomic/rails.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

require_relative 'option'
require_relative '../errgonomic'
require_relative 'rails/active_record_optional'
require_relative 'rails/active_record_delegate_optional'

Expand Down
15 changes: 13 additions & 2 deletions lib/errgonomic/rails/active_record_optional.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,19 @@ module ActiveRecordOptional
@errgonomic_optionals.each do |name|
class_eval <<-RUBY, __FILE__, __LINE__ + 1
def #{name}
raise "stack too deep" if caller.length > 1024
val = super
reads = Thread.current[:errgonomic_optional_reads] ||= {}
key = [object_id, :#{name}]
if reads[key]
raise Errgonomic::RecursiveOptionalReadError,
"\#{self.class}##{name} re-entered itself; something beneath this reader reads it again"
end

reads[key] = true
begin
val = super
ensure
reads.delete(key)
end
Comment on lines +21 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoa

val.nil? ? Errgonomic::Option::None.new : Errgonomic::Option::Some.new(val)
end
RUBY
Expand Down
36 changes: 36 additions & 0 deletions test/rails_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ class Genre < ActiveRecord::Base
delegate_optional :name, to: :parent, prefix: true, private: true
end

# A buggy layer that re-enters the attribute reader from beneath it: the
# generated reader's super lands here, and the unqualified call restarts
# dispatch at the top of the chain.
module ReentrantBio
def bio
bio
end
end

class LoopyAuthor < ActiveRecord::Base
self.table_name = 'authors'
include ReentrantBio
include Errgonomic::Rails::ActiveRecordOptional
end

class BugTest < Minitest::Test
def test_optional_attributes
author = Author.create!(name: 'Cixin Liu')
Expand Down Expand Up @@ -88,10 +103,31 @@ def test_delegate_optional
assert_equal author.name, book.author_name.unwrap!
end

# Wrapped readers must work on a deep-but-legitimate stack; genuine runaway
# recursion is SystemStackError's job.
def test_optional_attributes_read_on_a_deep_stack
author = Author.create!(name: 'Cixin Liu', bio: 'writes sci-fi')
deeper(1100) { assert_equal 'writes sci-fi', author.bio.unwrap! }
end

def test_recursive_read_raises_a_named_error_at_first_reentry
author = LoopyAuthor.create!(name: 'Cixin Liu', bio: 'writes sci-fi')
error = assert_raises(Errgonomic::RecursiveOptionalReadError) { author.bio }
assert_match(/bio/, error.message)
end

def test_private_delegate_optional
fiction = Genre.create!(name: 'Fiction')
scifi = Genre.create!(name: 'Sci-Fi', parent: fiction)
assert_raises(NoMethodError) { scifi.parent_name }
assert_equal 'Fiction', scifi.send(:parent_name).unwrap!
end

private

def deeper(frames, &block)
return block.call if frames.zero?

deeper(frames - 1, &block)
end
end
Loading