-
-
Notifications
You must be signed in to change notification settings - Fork 10k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Use JSON to marshal errors from children #4752
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
brew tests
is failing.
@@ -190,7 +190,20 @@ def fixopt(f) | |||
build = Build.new(formula, options) | |||
build.install | |||
rescue Exception => e # rubocop:disable Lint/RescueException | |||
Marshal.dump(e, error_pipe) | |||
error_hash = JSON.parse e.to_json |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why convert this to JSON and then to a hash?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So that we can handle adding extra information in the BuildError
case below -- Exception
doesn't have a #to_h
or a #to_hash
method, so I figured it was slightly cleaner to go from JSON to a hash than to duplicate write a monkeypatched method or Utils
method that does the same.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, works for me, just wondering 👍
Library/Homebrew/dev-cmd/test.rb
Outdated
when "Test::Unit::AssertionFailedError" | ||
puts e.inner["m"] | ||
else | ||
puts e.inner["json_class"], e.backtrace |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens to the exception message?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
e.inner["m"]
contains the message, so it still gets printed in the Test::Unit:AssertionFailedError
case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about the other case, sorry?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I duplicated the logic from below (the rescue Exception => e
), since it's basically the same failure mode (non-assertion failures, it's just that we're distinguishing between non-assertion failures in the child and parent now).
I can make it print the message explicitly if you'd like, in which case I think it makes sense to update the original fallthrough too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like above I think it might be nice to fiddle with handling ChildProcessError
elsewhere so this special handling isn't required at all here.
prefix.install "libexec" | ||
|
||
# This should get marshalled into a BuildError. | ||
system "this_command_must_never_execute" if ENV["FAILBALL_BUILD_ERROR"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you explain this line a bit?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup!
We're calling system
with a bogus command to induce a failure, which causes it to raise a BuildError
in the build process, per
brew/Library/Homebrew/formula.rb
Line 1878 in 9bc17c8
raise BuildError.new(self, cmd, args, env) |
That BuildError
contains a bunch of state that we use later on in analytics (and to tell the user what exactly went wrong), so we make a special case when catching that exception to re-add most of that state to the JSON blob that goes over the error pipe. The parent process reads that blob, realizes that it's a special case, and reconstructs a BuildError
instead of a more generic ChildProcessError
.
We do all of that only when ENV["FAILBALL_BUILD_ERROR"]
is set in the test harness, so that we can fall through to the more generic test case below (when something other than a command fails in the build process, and a generic ChildProcessError
gets reported back).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this use /usr/bin/false
instead?
Library/Homebrew/utils/fork.rb
Outdated
read.close | ||
Process.wait(pid) unless socket.nil? | ||
raise Marshal.load(data) unless data.nil? || data.empty? # rubocop:disable Security/MarshalLoad | ||
|
||
error_hash = JSON.parse(data) unless data.nil? || data.empty? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you refactor this into an if
Library/Homebrew/build.rb
Outdated
# BuildErrors are specific to build processses and not other | ||
# children, which is why we create the necessary state here | ||
# and not in Utils.safe_fork. | ||
if e.is_a?(BuildError) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any way to avoid all the is_a?
Maybe looking at the keys instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, error_hash["json_class"] == "BuildError"
should do the trick. I'll change it.
Library/Homebrew/utils/fork.rb
Outdated
|
||
if error_hash | ||
e = case error_hash["json_class"] | ||
when "ErrorDuringExecution" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any way of turning this into an actual class comparison?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably not at this point in the program -- we're comparing with strings because we're about to (re)create the classes that are coming from the error pipe in JSON form.
Yeah looks like there's something funny going on with the mocks for
I'll investigate. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
More thoughts on avoiding classes being sniffed around.
@@ -190,7 +190,20 @@ def fixopt(f) | |||
build = Build.new(formula, options) | |||
build.install | |||
rescue Exception => e # rubocop:disable Lint/RescueException | |||
Marshal.dump(e, error_pipe) | |||
error_hash = JSON.parse e.to_json |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, works for me, just wondering 👍
Library/Homebrew/dev-cmd/test.rb
Outdated
when "Test::Unit::AssertionFailedError" | ||
puts e.inner["m"] | ||
else | ||
puts e.inner["json_class"], e.backtrace |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about the other case, sorry?
e.options = display_options(formula) if e.is_a?(BuildError) | ||
# Utils.safe_fork rebuilds ErrorDuringExecutions for us, but doesn't have access | ||
# to the Formula object that we need to rebuild BuildErrors. | ||
if e.is_a?(ChildProcessError) && e.inner["json_class"] == "BuildError" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
More duck-typing might be nice here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I could make this do a respond_to? :inner_class
if we're good on the inner_class
solution above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually: like above I think it might be nice to fiddle with handling ChildProcessError
elsewhere so this special handling isn't required at all here.
# Special case: We need to recreate ErrorDuringExecutions | ||
# for proper error messages and because other code expects | ||
# to rescue them further down. | ||
if e.is_a?(ErrorDuringExecution) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
More duck-typing might be nice here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a little stuck on the ducktyping here -- I don't think we want to test the exception with responds_to?
here, since there are probably other exceptions that have cmd
/status
/output
attributes that we don't want to handle specially. I flattened the exception recreation below, though.
# BuildErrors are specific to build processses and not other | ||
# children, which is why we create the necessary state here | ||
# and not in Utils.safe_fork. | ||
if error_hash["json_class"] == "BuildError" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could this become a constant that's ducktyped?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably not here, since this is happening in the build's child process and we haven't produced the ChildProcessError
yet. I've minimized the other cases, though.
Library/Homebrew/dev-cmd/test.rb
Outdated
ofail "#{f.full_name}: failed" | ||
puts e.message | ||
case e.inner["json_class"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could this become a constant that's ducktyped?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or even just a constant that's is_a?
d?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I could add ChildProcessError.inner_class
that just calls const_get
on inner["json_class"]
, resulting in something like this:
if e.inner_class == ::Test::Unit::AssertionFailedError
puts e.inner["m"]
else
puts e.inner_class, e.backtrace
end
How does that sound?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeh, I think that'd be nicer 👍. I do wonder if this couldn't be done at a higher level somewhere (Utils.safe_fork
?) so that it doesn't need to be handled as JSON in so many places.
Library/Homebrew/utils/fork.rb
Outdated
error_hash = JSON.parse(data) unless data.nil? || data.empty? | ||
|
||
e = case error_hash["json_class"] | ||
when "ErrorDuringExecution" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could this become a constant that's ducktyped?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I could probably do the same trick as mentioned in #4752 (comment), although I'm not sure how to ducktype it per se.
Library/Homebrew/dev-cmd/test.rb
Outdated
ofail "#{f.full_name}: failed" | ||
puts e.message | ||
case e.inner["json_class"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeh, I think that'd be nicer 👍. I do wonder if this couldn't be done at a higher level somewhere (Utils.safe_fork
?) so that it doesn't need to be handled as JSON in so many places.
Library/Homebrew/dev-cmd/test.rb
Outdated
when "Test::Unit::AssertionFailedError" | ||
puts e.inner["m"] | ||
else | ||
puts e.inner["json_class"], e.backtrace |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like above I think it might be nice to fiddle with handling ChildProcessError
elsewhere so this special handling isn't required at all here.
e.options = display_options(formula) if e.is_a?(BuildError) | ||
# Utils.safe_fork rebuilds ErrorDuringExecutions for us, but doesn't have access | ||
# to the Formula object that we need to rebuild BuildErrors. | ||
if e.is_a?(ChildProcessError) && e.inner["json_class"] == "BuildError" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually: like above I think it might be nice to fiddle with handling ChildProcessError
elsewhere so this special handling isn't required at all here.
prefix.install "libexec" | ||
|
||
# This should get marshalled into a BuildError. | ||
system "this_command_must_never_execute" if ENV["FAILBALL_BUILD_ERROR"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this use /usr/bin/false
instead?
Library/Homebrew/utils/fork.rb
Outdated
status: error_hash["status"], | ||
output: error_hash["output"]) | ||
else | ||
ChildProcessError.new(error_hash) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could this be caught and handled somewhere elsewhere so the fact it's come from a child process doesn't need to be handled in each and every case above?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where else were you thinking? I'm not sure where else to put it, since fork.rb
is where the parent and child processes converge.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I could do something like this:
e = ChildProcessError.new(error_hash)
raise e unless e.inner_class == ErrorDuringExecution
# Rebuild and throw the ErrorDuringExecution here
That flattens the logic a bit and gets rid of the ugly string comparison above, but it also creates an extra ChildProcessError
that we never throw.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@woodruffw Yeh, something like that would be good, I think. It'd be good to recreate the original exception and throw it as "early" as possible so that things that are "later"/higher up the call stack can avoid needing to know or care about ChildProcessError
being a thing. It's possible I've misunderstood here, though, so please feel free to argue and disagree 😆
@woodruffw I'm doing a bad job explaining but I think what would be nice would be a function-level handling of |
If you mean a single method that converts There is also at least one case where we can't rebuild the exception directly inside of In sum: I could probably do something close to what you want, but I don't think it'll remove many of the caller changes I've had to make: we're still going to need to recreate the |
Cool. I hate to be a pain but: could we see that? Feel free to make it a bit hacky because if it's awful I may decide the current way is better?
My take is that the extracting of the "real" exception from
Could the formula object that failed be passed through instead? Alternatively, could a stub value be used here that's populated later?
Yeh, I just think they should have a layer containing them as far into the stack as possible and they don't leak beyond that point (even if that means refactoring some existing code so everywhere they could leak from goes through a single method/class). |
Not a pain at all! Due diligence is appreciated 😄 I'll push a changeset up for comparison sometime tomorrow, and lay out why exactly I don't think it's the best approach when I do. |
@MikeMcQuaid cacc5a9 has a version of your idea; I'm breaking the pros and cons out below: Pros:
Cons:
Ultimately, I writing this has changed my mind: I think the pros do outweigh the cons here, especially given that my original approach of handling |
98a43ef
to
cacc5a9
Compare
@@ -2,6 +2,26 @@ | |||
require "socket" | |||
|
|||
module Utils | |||
def self.rewrite_child_error(child_error) | |||
error = if child_error.inner_class == ErrorDuringExecution |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe a case
here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
case
uses #===
for comparison, so comparing a class directly doesn't work 😞:
case String
when String then :foo
else :bar
end # => :bar
I agree 👍
Would it be possible to create the new exception object based on that |
Probably not, since that would require arbitrary exception class instantiation (and that's what we're trying to eliminate by removing |
CI's failing, although it looks unrelated to these changes:
|
|
e046b64
to
7d1733a
Compare
Rebase did the trick 🎉 |
I'm happy with this now. Any other tweaks you want before merging this @woodruffw? If not: 🚢! |
Yeah, I'm happy with it too. I'll squash down into a few manageable/feature commits and merge in a bit. Thanks for the review! |
Replaces our serialization of child process errors via Marshal with JSON, preventing unintentional or malicious code execution outside of the build sandbox. Additionally, adds tests for the new behavior.
7d1733a
to
86b9647
Compare
This seems to have broken analytics. $ brew formula-analytics go
Error: badRequest: Invalid JSON payload received. Unknown name "json_class" at 'report_requests[0].dimension_filter_clauses[0].filters[0].expressions[0]': Cannot find field.
Invalid JSON payload received. Unknown name "s" at 'report_requests[0].dimension_filter_clauses[0].filters[0].expressions[0]': Cannot find field.
Invalid JSON payload received. Unknown name "json_class" at 'report_requests[0].sampling_level': Cannot find field.
Invalid JSON payload received. Unknown name "s" at 'report_requests[0].sampling_level': Cannot find field.
Invalid JSON payload received. Unknown name "json_class" at 'report_requests[1].dimension_filter_clauses[0].filters[0].expressions[0]': Cannot find field.
Invalid JSON payload received. Unknown name "s" at 'report_requests[1].dimension_filter_clauses[0].filters[0].expressions[0]': Cannot find field.
Invalid JSON payload received. Unknown name "json_class" at 'report_requests[1].sampling_level': Cannot find field.
Invalid JSON payload received. Unknown name "s" at 'report_requests[1].sampling_level': Cannot find field.
Invalid JSON payload received. Unknown name "json_class" at 'report_requests[2].dimension_filter_clauses[0].filters[0].expressions[0]': Cannot find field.
Invalid JSON payload received. Unknown name "s" at 'report_requests[2].dimension_filter_clauses[0].filters[0].expressions[0]': Cannot find field.
Invalid JSON payload received. Unknown name "json_class" at 'report_requests[2].sampling_level': Cannot find field.
Invalid JSON payload received. Unknown name "s" at 'report_requests[2].sampling_level': Cannot find field.
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/google-api-client-0.23.6/lib/google/apis/core/http_command.rb:218:in `check_status'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/google-api-client-0.23.6/lib/google/apis/core/api_command.rb:116:in `check_status'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/google-api-client-0.23.6/lib/google/apis/core/http_command.rb:183:in `process_response'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/google-api-client-0.23.6/lib/google/apis/core/batch.rb:87:in `block in decode_response_body'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/google-api-client-0.23.6/lib/google/apis/core/batch.rb:81:in `each_index'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/google-api-client-0.23.6/lib/google/apis/core/batch.rb:81:in `decode_response_body'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/google-api-client-0.23.6/lib/google/apis/core/http_command.rb:184:in `process_response'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/google-api-client-0.23.6/lib/google/apis/core/http_command.rb:299:in `execute_once'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/google-api-client-0.23.6/lib/google/apis/core/http_command.rb:104:in `block (2 levels) in execute'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/retriable-3.1.2/lib/retriable.rb:61:in `block in retriable'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/retriable-3.1.2/lib/retriable.rb:56:in `times'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/retriable-3.1.2/lib/retriable.rb:56:in `retriable'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/google-api-client-0.23.6/lib/google/apis/core/http_command.rb:101:in `block in execute'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/retriable-3.1.2/lib/retriable.rb:61:in `block in retriable'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/retriable-3.1.2/lib/retriable.rb:56:in `times'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/retriable-3.1.2/lib/retriable.rb:56:in `retriable'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/google-api-client-0.23.6/lib/google/apis/core/http_command.rb:93:in `execute'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/vendor/ruby/ruby/2.3.0/gems/google-api-client-0.23.6/lib/google/apis/core/base_service.rb:178:in `batch'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/cmd/brew-formula-analytics.rb:212:in `block in <top (required)>'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/cmd/brew-formula-analytics.rb:209:in `each'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/cmd/brew-formula-analytics.rb:209:in `each_slice'
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-formula-analytics/cmd/brew-formula-analytics.rb:209:in `<top (required)>'
/System/Library/Frameworks/Ruby.framework/Versions/2.3/usr/lib/ruby/2.3.0/rubygems/core_ext/kernel_require.rb:55:in `require'
/System/Library/Frameworks/Ruby.framework/Versions/2.3/usr/lib/ruby/2.3.0/rubygems/core_ext/kernel_require.rb:55:in `require'
/usr/local/Homebrew/Library/Homebrew/utils.rb:19:in `require?'
/usr/local/Homebrew/Library/Homebrew/brew.rb:95:in `<main>' |
Ugh, thought I had fixed that. I'm guessing (since I don't know much about GA or our analytics code) that the issue here is some new keys ( |
The core's travis test is also failing due to the breakage of analytics. https://travis-ci.org/Homebrew/homebrew-core/builds/424184064
|
brew style
with your changes locally?brew tests
with your changes locally?Take two on #4717:
This replaces our use of Marshal to send exceptions from child processes to their controlling parents with a JSON representation of the exception state. This JSON representation is then wrapped in a new exception class, ChildProcessError. For particularly interesting exceptions in child processes (namely
BuildError
s andErrorDuringExecution
s), we reify the ChildProcessError back into its original exception (which keeps analytics and other parts of the codebase that use it directly happy).Adds tests for
FormulaInstaller
andUtils.safe_fork
.