redis-rb: _write_to_socket is changing input, which breaks write

This code is amending the input of the data

https://github.com/redis/redis-rb/blob/d896ae2c7391ba9af667ee3ec8f70192e8daf1da/lib/redis/connection/ruby.rb#L87-L87

And so is this code:

https://github.com/redis/redis-rb/blob/d896ae2c7391ba9af667ee3ec8f70192e8daf1da/lib/redis/connection/ruby.rb#L106-L106

Simplest fix here is simply to stop the slice! call in _write_to_socket

This is very urgent @byroot latest release is broken. Ideally we should have some sort of test to catch this.

End result is that if you are lucky, partial things are shipped to redis, if you are unlucky stuff explodes on:

NoMethodError: undefined method `bytesize' for nil:NilClass
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/connection/ruby.rb:103:in `block in write'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/connection/ruby.rb:102:in `loop'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/connection/ruby.rb:102:in `write'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/connection/ruby.rb:394:in `write'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/client.rb:284:in `block in write'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/client.rb:263:in `io'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/client.rb:282:in `write'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/client.rb:240:in `block (3 levels) in process'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/client.rb:234:in `each'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/client.rb:234:in `block (2 levels) in process'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/client.rb:384:in `ensure_connected'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/client.rb:233:in `block in process'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/client.rb:320:in `logging'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/client.rb:232:in `process'
  /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/redis-4.2.3/lib/redis/client.rb:126:in `call'

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Comments: 22 (10 by maintainers)

Commits related to this issue

Most upvoted comments

So I wrote the following benchmark:

require 'benchmark/ips'

class FakeIO
  def write_nonblock(buffer, exception: false)
    raise 'wtf' if buffer.empty?
    [buffer.bytesize, 65_527].min # popular socket buffer size
  end
end

class CurrentBufferedWriter
  def initialize(io)
    @io = io
  end

  def _write_to_socket(data)
    total_bytes_written = 0
    loop do
      case bytes_written = @io.write_nonblock(data, exception: false)
      when :wait_readable
        unless wait_readable(@write_timeout)
          raise Redis::TimeoutError
        end
      when :wait_writable
        unless wait_writable(@write_timeout)
          raise Redis::TimeoutError
        end
      when nil
        raise Errno::ECONNRESET
      when Integer
        total_bytes_written += bytes_written
        if bytes_written < data.bytesize
          data.slice!(0, bytes_written)
        else
          return total_bytes_written
        end
      end
    end
  end

  def write(data)
    data = data.b
    length = data.bytesize
    total_count = 0
    loop do
      count = _write_to_socket(data)

      total_count += count
      return total_count if total_count >= length

      data = data.byteslice(count..-1)
    end
  end
end

class MutatingBufferedWriter
  def initialize(io)
    @io = io
  end

  def write(data)
    buffer = data.b
    length = data.bytesize
    total_bytes_written = 0
    loop do
      case bytes_written = @io.write_nonblock(buffer, exception: false)
      when :wait_readable
        unless wait_readable(@write_timeout)
          raise Redis::TimeoutError
        end
      when :wait_writable
        unless wait_writable(@write_timeout)
          raise Redis::TimeoutError
        end
      when nil
        raise Errno::ECONNRESET
      when Integer
        total_bytes_written += bytes_written
        return total_bytes_written if total_bytes_written >= length
        buffer.slice!(0, bytes_written)
      end
    end
  end
end


class CopyingBufferedWriter
  def initialize(io)
    @io = io
  end

  def write(data)
    length = data.bytesize
    total_bytes_written = 0
    loop do
      case bytes_written = @io.write_nonblock(data, exception: false)
      when :wait_readable
        unless wait_readable(@write_timeout)
          raise Redis::TimeoutError
        end
      when :wait_writable
        unless wait_writable(@write_timeout)
          raise Redis::TimeoutError
        end
      when nil
        raise Errno::ECONNRESET
      when Integer
        total_bytes_written += bytes_written
        return total_bytes_written if total_bytes_written >= length
        data = data.byteslice(bytes_written..-1)
      end
    end
  end
end

current = CurrentBufferedWriter.new(FakeIO.new)
mutating = MutatingBufferedWriter.new(FakeIO.new)
copying = CopyingBufferedWriter.new(FakeIO.new)

PAYLOAD = ("\u3042" * 32_000).freeze

Benchmark.ips do |x|
  x.config(:time => 10, :warmup => 2)

  x.report('current') { current.write(PAYLOAD) }
  x.report('mutating') { mutating.write(PAYLOAD) }
  x.report('copying') { copying.write(PAYLOAD) }

  x.compare!
end

On 2.7.1 it get:

Warming up --------------------------------------
             current     1.335k i/100ms
            mutating     1.384k i/100ms
             copying   108.792k i/100ms
Calculating -------------------------------------
             current     14.313k (± 6.5%) i/s -    142.845k in  10.023401s
            mutating     14.307k (± 6.4%) i/s -    142.552k in  10.006148s
             copying      1.101M (± 1.2%) i/s -     11.097M in  10.082546s

Comparison:
             copying:  1100764.6 i/s
             current:    14313.2 i/s - 76.91x  (± 0.00) slower
            mutating:    14306.6 i/s - 76.94x  (± 0.00) slower

The difference is so big that I can hardly believe it.

But maybe I messed that too.

Seems like I did…

We need to copy that buffer in write(), with data = data.b.