Skip to content

Commit 81ffec2

Browse files
authored
Merge pull request #119 from perihelios/outputs-left-open
Fix for #118 (agent hangs if STDOUT/STDERR left open by hook)
2 parents be2e1a3 + 26d5ee4 commit 81ffec2

File tree

5 files changed

+164
-7
lines changed

5 files changed

+164
-7
lines changed

lib/instance_agent.rb

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
require 'instance_agent/log'
1515
require 'instance_agent/platform'
1616
require 'instance_agent/platform/linux_util'
17+
require 'instance_agent/platform/thread_joiner'
1718
require 'instance_agent/agent/plugin'
1819
require 'instance_agent/agent/base'
1920
require 'instance_agent/runner/master'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module InstanceAgent
2+
class ThreadJoiner
3+
def initialize(timeout_sec)
4+
@timeout_epoch = Time.now.to_i + timeout_sec
5+
end
6+
7+
def joinOrFail(thread, &block)
8+
if !thread.join([@timeout_epoch - Time.now.to_i, 0].max)
9+
yield(thread) if block_given?
10+
end
11+
end
12+
end
13+
end

lib/instance_agent/plugins/codedeploy/hook_executor.rb

+13-3
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class ScriptError < StandardError
4747
SCRIPT_TIMED_OUT_CODE = 3
4848
SCRIPT_FAILED_CODE = 4
4949
UNKNOWN_ERROR_CODE = 5
50+
OUTPUTS_LEFT_OPEN_CODE = 6
5051
def initialize(error_code, script_name, log)
5152
@error_code = error_code
5253
@script_name = script_name
@@ -160,12 +161,21 @@ def execute_script(script, script_log_file)
160161
stdin.close
161162
stdout_thread = Thread.new{stdout.each_line { |line| log_script("[stdout]" + line.to_s, script_log_file)}}
162163
stderr_thread = Thread.new{stderr.each_line { |line| log_script("[stderr]" + line.to_s, script_log_file)}}
163-
if !wait_thr.join(script.timeout)
164+
thread_joiner = InstanceAgent::ThreadJoiner.new(script.timeout)
165+
thread_joiner.joinOrFail(wait_thr) do
164166
Process.kill(signal, wait_thr.pid)
165167
raise Timeout::Error
166168
end
167-
stdout_thread.join
168-
stderr_thread.join
169+
thread_joiner.joinOrFail(stdout_thread) do
170+
script_error = "Script at specified location: #{script.location} failed to close STDOUT"
171+
log :error, script_error
172+
raise ScriptError.new(ScriptError::OUTPUTS_LEFT_OPEN_CODE, script.location, @script_log), script_error
173+
end
174+
thread_joiner.joinOrFail(stderr_thread) do
175+
script_error = "Script at specified location: #{script.location} failed to close STDERR"
176+
log :error, script_error
177+
raise ScriptError.new(ScriptError::OUTPUTS_LEFT_OPEN_CODE, script.location, @script_log), script_error
178+
end
169179
exit_status = wait_thr.value.exitstatus
170180
end
171181
if(exit_status != 0)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
require 'test_helper'
2+
3+
class ThreadJoinerTest < InstanceAgentTestCase
4+
context 'ThreadJoiner' do
5+
setup do
6+
@timeout_sec = 30
7+
@thread1 = mock('thread1')
8+
@thread2 = mock('thread2')
9+
@start_time = Time.now
10+
Time.stubs(:now).returns(start_time)
11+
@joiner = InstanceAgent::ThreadJoiner.new(@timeout_sec)
12+
end
13+
14+
context 'with time left' do
15+
setup do
16+
@thread1.expects(:join).with(@timeout_sec).returns(1)
17+
@thread2.expects(:join).with(@timeout_sec - 13).returns(1)
18+
end
19+
20+
should 'join threads with proper timeout' do
21+
@joiner.joinOrFail(@thread1)
22+
Time.stubs(:now).returns(@start_time + 13)
23+
@joiner.joinOrFail(@thread2)
24+
end
25+
end
26+
27+
context 'with no time left' do
28+
setup do
29+
@thread1.expects(:join).with(0)
30+
@thread2.expects(:join).with(0)
31+
end
32+
33+
should 'join threads for zero seconds' do
34+
Time.stubs(:now).returns(@start_time + @timeout_sec)
35+
@joiner.joinOrFail(@thread1)
36+
Time.stubs(:now).returns(@start_time + @timeout_sec + 1)
37+
@joiner.joinOrFail(@thread2)
38+
end
39+
end
40+
41+
context 'when a block is provided' do
42+
context 'and thread does not complete' do
43+
setup do
44+
@thread1.expects(:join).returns(nil)
45+
end
46+
47+
should 'call block' do
48+
called = false
49+
@joiner.joinOrFail(@thread1) do
50+
called = true
51+
end
52+
53+
assert_true called
54+
end
55+
56+
should 'pass thread to block' do
57+
thread = nil
58+
@joiner.joinOrFail(@thread1) do | th |
59+
thread = th
60+
end
61+
62+
assert_equal @thread1, thread
63+
end
64+
65+
should 'propagate exception back from block' do
66+
assert_raise RuntimeError do
67+
@joiner.joinOrFail(@thread1) do
68+
raise 'thread did not complete'
69+
end
70+
end
71+
end
72+
end
73+
74+
context 'and thread completes' do
75+
setup do
76+
@thread1.expects(:join).returns(1)
77+
end
78+
79+
should 'not call block' do
80+
called = false
81+
@joiner.joinOrFail(@thread1) do
82+
called = true
83+
end
84+
85+
assert_false called
86+
end
87+
end
88+
end
89+
end
90+
end

test/instance_agent/plugins/codedeploy/hook_executor_test.rb

+47-4
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ def create_full_hook_executor
173173
File.stubs(:exist?).with(@script_location).returns(false)
174174
end
175175

176-
should "raise and exception" do
176+
should "raise an exception" do
177177
assert_raised_with_message("Script does not exist at specified location: #{File.expand_path(@deployment_root_dir)}/deployment-archive/test", ScriptError)do
178178
@hook_executor.execute
179179
end
@@ -224,6 +224,9 @@ def create_full_hook_executor
224224
@value = mock
225225
@wait_thr.stubs(:value).returns(@value)
226226
@wait_thr.stubs(:join).returns(1000)
227+
@thread_joiner = mock('thread_joiner')
228+
@thread_joiner.stubs(:joinOrFail)
229+
InstanceAgent::ThreadJoiner.stubs(:new).returns(@thread_joiner)
227230
end
228231

229232
context 'scripts fail for unknown reason' do
@@ -248,9 +251,9 @@ def create_full_hook_executor
248251
@app_spec = { "version" => 0.0, "os" => "linux", "hooks" => {'ValidateService'=> [{"location"=>"test", "timeout"=>"30"}]}}
249252
YAML.stubs(:load).returns(@app_spec)
250253
@hook_executor = create_full_hook_executor
251-
@wait_thr.stubs(:join).with(30).returns(nil)
254+
@thread_joiner.expects(:joinOrFail).with(@wait_thr).yields
255+
InstanceAgent::ThreadJoiner.expects(:new).with(30).returns(@thread_joiner)
252256
@wait_thr.stubs(:pid).returns(1234)
253-
mock_pipe = mock
254257
end
255258

256259
context "with process group support" do
@@ -281,7 +284,47 @@ def create_full_hook_executor
281284
end
282285
end
283286
end
284-
287+
288+
context "scripts fail to close outputs" do
289+
setup do
290+
timeout = 144
291+
InstanceAgent::ThreadJoiner.expects(:new).with(timeout).returns(@thread_joiner)
292+
@stdout_thread = mock('stdout_thread')
293+
@stderr_thread = mock('stderr_thread')
294+
Thread.stubs(:new).returns(@stdout_thread, @stderr_thread)
295+
Open3.stubs(:popen3).with(@child_env, @script_location, :pgroup => true).yields([@mock_pipe,@mock_pipe,@mock_pipe,@wait_thr])
296+
@app_spec = {"version" => 0.0, "os" => "linux", "hooks" => {'ValidateService'=>[{'location'=>'test', 'timeout'=>"#{timeout}"}]}}
297+
YAML.stubs(:load).returns(@app_spec)
298+
@hook_executor = create_full_hook_executor
299+
end
300+
301+
context "STDOUT left open" do
302+
setup do
303+
@thread_joiner.expects(:joinOrFail).with(@stdout_thread).yields
304+
InstanceAgent::Log.expects(:send).with(:error, "InstanceAgent::Plugins::CodeDeployPlugin::HookExecutor: Script at specified location: test failed to close STDOUT")
305+
end
306+
307+
should "raise an exception" do
308+
assert_raised_with_message("Script at specified location: test failed to close STDOUT", ScriptError) do
309+
@hook_executor.execute
310+
end
311+
end
312+
end
313+
314+
context "STDERR left open" do
315+
setup do
316+
@thread_joiner.expects(:joinOrFail).with(@stderr_thread).yields
317+
InstanceAgent::Log.expects(:send).with(:error, "InstanceAgent::Plugins::CodeDeployPlugin::HookExecutor: Script at specified location: test failed to close STDERR")
318+
end
319+
320+
should "raise an exception" do
321+
assert_raised_with_message("Script at specified location: test failed to close STDERR", ScriptError) do
322+
@hook_executor.execute
323+
end
324+
end
325+
end
326+
end
327+
285328
context "Scripts run with a runas" do
286329
setup do
287330
@app_spec = { "version" => 0.0, "os" => "linux", "hooks" => {'ValidateService'=> [{"location"=>"test", "runas"=>"user"}]}}

0 commit comments

Comments
 (0)