From 3cf3d6ae97044f67c29c2ea30066a706ff99ecb1 Mon Sep 17 00:00:00 2001 From: Felix-LeeSM Date: Tue, 2 Jun 2026 10:37:11 +0900 Subject: [PATCH] Make hook context input reads thread-safe Hook contexts cache stdin lazily through input_string. Since hooks run in parallel by default, two hooks can race during that first read from the shared input stream. That is especially visible in pre-push hooks, where Git provides refs on stdin. One thread can consume the refs, while another reads EOF and caches an empty string. If that happens, helpers like pushed_refs can silently see no refs. Use a mutex around the first read so the stream is consumed once and every caller gets the same cached input. Fixes sds/overcommit#881 --- lib/overcommit/hook_context/base.rb | 9 +++- spec/overcommit/hook_context/base_spec.rb | 51 +++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/lib/overcommit/hook_context/base.rb b/lib/overcommit/hook_context/base.rb index b50698c9..ec318a3c 100644 --- a/lib/overcommit/hook_context/base.rb +++ b/lib/overcommit/hook_context/base.rb @@ -24,6 +24,7 @@ def initialize(config, args, input, **options) @args = args @input = input @options = options + @input_mutex = Mutex.new end # Executes a command as if it were a regular git hook, passing all @@ -95,7 +96,13 @@ def all_files # # @return [String] def input_string - @input_string ||= @input.read + return @input_string if defined?(@input_string) + + @input_mutex.synchronize do + @input_string = @input.read unless defined?(@input_string) + end + + @input_string end # Returns an array of lines passed to the hook via the standard input diff --git a/spec/overcommit/hook_context/base_spec.rb b/spec/overcommit/hook_context/base_spec.rb index 7b72c12e..ecae943b 100644 --- a/spec/overcommit/hook_context/base_spec.rb +++ b/spec/overcommit/hook_context/base_spec.rb @@ -26,6 +26,57 @@ it { should == ['line 1', 'line 2'] } end + describe '#input_string' do + let(:input_class) do + Class.new do + attr_reader :read_count + + def initialize(value) + @value = value + @read_count = 0 + @lock = Mutex.new + @read_started = Queue.new + @read_finished = Queue.new + end + + def read + @lock.synchronize do + @read_count += 1 + raise 'input stream was read more than once' if @read_count > 1 + end + + @read_started << true + @read_finished.pop + @value + end + + def wait_until_reading + @read_started.pop + end + + def finish_reading + @read_finished << true + end + end + end + + let(:input) { input_class.new("line 1\nline 2\n") } + + it 'shares one input stream read across concurrent callers' do + first_reader = Thread.new { context.input_string } + input.wait_until_reading + + second_reader = Thread.new { context.input_string } + Thread.pass until second_reader.status == 'sleep' + + input.finish_reading + results = [first_reader, second_reader].map(&:value) + + results.should == Array.new(2, "line 1\nline 2\n") + input.read_count.should == 1 + end + end + describe '#post_fail_message' do subject { context.post_fail_message }